1
0
forked from baron/baron-sso

조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인

This commit is contained in:
2026-05-11 20:13:54 +09:00
parent d3853fac2a
commit 3063450ee0
59 changed files with 5086 additions and 549 deletions

View File

@@ -0,0 +1,23 @@
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e9]:
- heading "Baron SSO" [level=1] [ref=e10]
- paragraph [ref=e11]: Developer Control Plane
- generic [ref=e12]:
- generic [ref=e13]:
- heading "개발자 포털 로그인" [level=3] [ref=e14]:
- img [ref=e15]
- text: 개발자 포털 로그인
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
- generic [ref=e19]:
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
- img [ref=e21]
- text: SSO 계정으로 로그인
- img [ref=e23]
- paragraph [ref=e27]:
- text: 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
- paragraph [ref=e32]:
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
- text: 시스템 관리자에게 문의하세요.

50
adminfront/gpdtdc_org.csv Normal file
View File

@@ -0,0 +1,50 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","hr@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"전산관리TF","4","한치영(cyhan@samaneng.com)","","","it@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"기술기획","8","김원기(ba.56669@baroncs.co.kr)","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"경영기획","0","","","","t_266py@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"ERP기획","0","","","","t_136ud@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"디자인기획","0","","","","t_618gm@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"협업증진","0","","","","t_752rp@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"솔루션통합","0","","","","t_683tq@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su3@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","t_536fc@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","t_568cz@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"DfMA","0","","","","t_538ub@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"일반구조물","0","","","","t_601cu@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"구조물계획","0","","","","t_388gh@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"하부구조","0","","","","t_131xd@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"CM기획","0","","","","t_349dy@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"터널","0","","","","t_068jk@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"CC","0","","","","t_116me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"공정관리","0","","","","t_628of@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
"단가산출","0","","","","t_002sq@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
"상하수도","0","","","","t_323pd@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"천지인","0","","","","t_859sx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"천지인셀","0","","","","t_827ax@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"용지도셀","0","","","","t_896yy@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"단지설계 개발","0","","","","t_602uo@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","t_566mk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"비탈면/구조물","0","","","","t_726dh@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Way Draw","0","","","","t_504jn@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Primal 평면","0","","","","t_284vk@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Watch BIM","0","","","","t_170el@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"구조물S/W","0","","","","t_019ge@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"Strana","0","","","","t_595rj@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"그래픽스","0","","","","t_934zk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"Modeler","0","","","","t_932vs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"HmEG","0","","","","t_614xb@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"EG-BIM Draw","0","","","","t_563cv@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","t_762fs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"웹솔루션","0","","","","t_797wn@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"솔루션개발","0","","","","t_923oe@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"ERP","0","","","","t_481sa@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"웹디자인","0","","","","t_587ef@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"GSIM개발","0","","","","t_929kx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"bCMf","0","","","","t_833jy@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"GSIM","0","","","","t_263tv@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"PM","0","","","","t_335nb@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"수자원","0","","","","t_233cs@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"스마트건설","0","","","","t_842me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"시공BIM","0","","","","t_942jh@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 gpd@baroncs.co.kr Y N Y Y
3 인재성장 2 hr@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
4 전산관리TF 4 한치영(cyhan@samaneng.com) it@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
5 기술기획 8 김원기(ba.56669@baroncs.co.kr) tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
6 경영기획 0 t_266py@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
7 ERP기획 0 t_136ud@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
8 디자인기획 0 t_618gm@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
9 협업증진 0 t_752rp@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
10 솔루션통합 0 t_683tq@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
11 네이버웍스관리용 2 슈퍼관리자(su-@samaneng.com) su3@baroncs.co.kr N N N Y
12 기술개발센터 0 t_536fc@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 t_568cz@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
14 DfMA 0 t_538ub@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
15 일반구조물 0 t_601cu@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
16 구조물계획 0 t_388gh@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
17 하부구조 0 t_131xd@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
18 CM기획 0 t_349dy@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
19 터널 0 t_068jk@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
20 CC 0 t_116me@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
21 공정관리 0 t_628of@baroncs.co.kr Y N Y Y CC(t_116me@baroncs.co.kr)
22 단가산출 0 t_002sq@baroncs.co.kr Y N Y Y CC(t_116me@baroncs.co.kr)
23 상하수도 0 t_323pd@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
24 천지인 0 t_859sx@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
25 천지인셀 0 t_827ax@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
26 용지도셀 0 t_896yy@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
27 단지설계 개발 0 t_602uo@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
28 인프라솔루션 개발 0 t_566mk@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
29 비탈면/구조물 0 t_726dh@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
30 Way Draw 0 t_504jn@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
31 Primal 평면 0 t_284vk@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
32 Watch BIM 0 t_170el@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
33 구조물S/W 0 t_019ge@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
34 Strana 0 t_595rj@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
35 그래픽스 0 t_934zk@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
36 Modeler 0 t_932vs@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
37 HmEG 0 t_614xb@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
38 EG-BIM Draw 0 t_563cv@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
39 Abut&시공통합관제 0 t_762fs@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
40 웹솔루션 0 t_797wn@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
41 솔루션개발 0 t_923oe@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
42 ERP 0 t_481sa@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
43 웹디자인 0 t_587ef@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
44 GSIM개발 0 t_929kx@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
45 bCMf 0 t_833jy@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
46 GSIM 0 t_263tv@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
47 PM 0 t_335nb@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
48 수자원 0 t_233cs@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
49 스마트건설 0 t_842me@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
50 시공BIM 0 t_942jh@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)

View File

@@ -0,0 +1,50 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","general-planning@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
"네이버웍스관리용","2","","","","nw-admin-gpd@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","rnd-center@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 general-planning@baroncs.co.kr Y N Y Y
3 인재성장 2 talent-growth@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
4 전산관리TF 4 it-admin-tf@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
5 기술기획 8 tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
6 경영기획 0 management-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
7 ERP기획 0 erp-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
8 디자인기획 0 design-planning@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
9 협업증진 0 collaboration@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
10 솔루션통합 0 solution-integration@baroncs.co.kr Y N Y Y 총괄기획실(general-planning@baroncs.co.kr)
11 네이버웍스관리용 2 nw-admin-gpd@baroncs.co.kr N N N Y
12 기술개발센터 0 rnd-center@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 structural-division@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
14 DfMA 0 dfma@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
15 일반구조물 0 structural-design@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
16 구조물계획 0 structure-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
17 하부구조 0 substructure@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
18 CM기획 0 cm-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
19 터널 0 tunnel@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
20 CC 0 cost-control@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
21 공정관리 0 schedule-control@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
22 단가산출 0 cost-estimate@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
23 상하수도 0 water-sewer@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
24 천지인 0 cheonjijin@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
25 천지인셀 0 cheonjijin-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
26 용지도셀 0 land-map-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
27 단지설계 개발 0 site-design-dev@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
28 인프라솔루션 개발 0 infra-solutions@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
29 비탈면/구조물 0 slope-structures@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
30 Way Draw 0 way-draw@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
31 Primal 평면 0 primal-plan@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
32 Watch BIM 0 watch-bim@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
33 구조물S/W 0 structural-software@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
34 Strana 0 strana@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
35 그래픽스 0 graphics@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
36 Modeler 0 modeler@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
37 HmEG 0 hmeg@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
38 EG-BIM Draw 0 eg-bim-draw@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
39 Abut&시공통합관제 0 abut-control@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
40 웹솔루션 0 web-solutions@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
41 솔루션개발 0 solution-dev@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
42 ERP 0 erp@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
43 웹디자인 0 web-design@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
44 GSIM개발 0 gsim-dev@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
45 bCMf 0 bcmf@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
46 GSIM 0 gsim@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
47 PM 0 project-management@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
48 수자원 0 water-resources@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
49 스마트건설 0 smart-construction@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)
50 시공BIM 0 construction-bim@baroncs.co.kr Y N Y Y 기술개발센터(rnd-center@baroncs.co.kr)

44
adminfront/saman_org.csv Normal file
View File

@@ -0,0 +1,44 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"기술개발센터","1","","","","tdc@samaneng.com","N","N","N","Y","","",""
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
"기획부","1","변역근(ykbyun@samaneng.com)","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"업무팀","0","","","","t_226wn@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"PQ팀","0","","","","t_978bl@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"재무회계팀","0","","","","t_186qz@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"대외협력팀","0","","","","t_466et@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"인사총무부","0","","","","t_784bn@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"네이버웍스관리용","1","슈퍼관리자(su-@samaneng.com)","","","su1@samaneng.com","N","N","N","Y","","",""
"자산경영실","0","","","","t_563wl@samaneng.com","Y","N","Y","Y","","",""
"안전품질관리실","0","","","","t_793co@samaneng.com","Y","N","Y","Y","","",""
"사업개발실","0","","","","t_468yk@samaneng.com","Y","N","Y","Y","","",""
"CM본부","0","","","","t_838vr@samaneng.com","Y","N","Y","Y","","",""
"CM사업부","0","","","","t_205ud@samaneng.com","Y","N","Y","Y","","","CM본부(t_838vr@samaneng.com)"
"호남지역총괄본부","0","","","","t_143ep@samaneng.com","Y","N","Y","Y","","","CM사업부(t_205ud@samaneng.com)"
"플랜트본부","0","","","","t_009bl@samaneng.com","Y","N","Y","Y","","",""
"플랜트1부","0","","","","t_595bv@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"플랜트2부","0","","","","t_677ei@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"항만부","0","","","","t_446wi@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"국토개발본부","0","","","","t_405cl@samaneng.com","Y","N","Y","Y","","",""
"도시계획부","0","","","","t_403or@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"도시개발부","0","","","","t_733kg@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"조경레저부","0","","","","t_931rr@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"도로본부","0","","","","t_402qv@samaneng.com","Y","N","Y","Y","","",""
"도로부","0","","","","t_560mk@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"지반터널부","0","","","","t_918nd@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"교통계획부","0","","","","t_879qs@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"구조부","0","","","","t_772wv@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"안전진단팀","0","","","","t_875hr@samaneng.com","Y","N","Y","Y","","","구조부(t_772wv@samaneng.com)"
"철도본부","0","","","","t_772tf@samaneng.com","Y","N","Y","Y","","",""
"철도1부","0","","","","t_879yn@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"철도2부","0","","","","t_025sm@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"환경평가부","0","","","","t_974cd@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"물환경본부","0","","","","t_857zu@samaneng.com","Y","N","Y","Y","","",""
"물환경1부","0","","","","t_881eq@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"물환경2부","0","","","","t_308je@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"물환경3부","0","","","","t_187qk@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"수자원본부","0","","","","t_415tw@samaneng.com","Y","N","Y","Y","","",""
"수자원1부","0","","","","t_237op@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"수자원2부","0","","","","t_989os@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"수력부","0","","","","t_175zq@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"해외사업본부","0","","","","t_436jd@samaneng.com","Y","N","Y","Y","","",""
"해외사업부","0","","","","t_099um@samaneng.com","Y","N","Y","Y","","","해외사업본부(t_436jd@samaneng.com)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 기술개발센터 1 tdc@samaneng.com N N N Y
3 경영전략본부 0 business-strategy@samaneng.com Y N Y Y
4 기획부 1 변역근(ykbyun@samaneng.com) planning@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
5 업무팀 0 t_226wn@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
6 PQ팀 0 t_978bl@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
7 재무회계팀 0 t_186qz@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
8 대외협력팀 0 t_466et@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
9 인사총무부 0 t_784bn@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
10 네이버웍스관리용 1 슈퍼관리자(su-@samaneng.com) su1@samaneng.com N N N Y
11 자산경영실 0 t_563wl@samaneng.com Y N Y Y
12 안전품질관리실 0 t_793co@samaneng.com Y N Y Y
13 사업개발실 0 t_468yk@samaneng.com Y N Y Y
14 CM본부 0 t_838vr@samaneng.com Y N Y Y
15 CM사업부 0 t_205ud@samaneng.com Y N Y Y CM본부(t_838vr@samaneng.com)
16 호남지역총괄본부 0 t_143ep@samaneng.com Y N Y Y CM사업부(t_205ud@samaneng.com)
17 플랜트본부 0 t_009bl@samaneng.com Y N Y Y
18 플랜트1부 0 t_595bv@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
19 플랜트2부 0 t_677ei@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
20 항만부 0 t_446wi@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
21 국토개발본부 0 t_405cl@samaneng.com Y N Y Y
22 도시계획부 0 t_403or@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
23 도시개발부 0 t_733kg@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
24 조경레저부 0 t_931rr@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
25 도로본부 0 t_402qv@samaneng.com Y N Y Y
26 도로부 0 t_560mk@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
27 지반터널부 0 t_918nd@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
28 교통계획부 0 t_879qs@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
29 구조부 0 t_772wv@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
30 안전진단팀 0 t_875hr@samaneng.com Y N Y Y 구조부(t_772wv@samaneng.com)
31 철도본부 0 t_772tf@samaneng.com Y N Y Y
32 철도1부 0 t_879yn@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
33 철도2부 0 t_025sm@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
34 환경평가부 0 t_974cd@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
35 물환경본부 0 t_857zu@samaneng.com Y N Y Y
36 물환경1부 0 t_881eq@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
37 물환경2부 0 t_308je@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
38 물환경3부 0 t_187qk@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
39 수자원본부 0 t_415tw@samaneng.com Y N Y Y
40 수자원1부 0 t_237op@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
41 수자원2부 0 t_989os@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
42 수력부 0 t_175zq@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
43 해외사업본부 0 t_436jd@samaneng.com Y N Y Y
44 해외사업부 0 t_099um@samaneng.com Y N Y Y 해외사업본부(t_436jd@samaneng.com)

View File

@@ -0,0 +1,44 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"기술개발센터","1","","","","tech-dev-center@samaneng.com","N","N","N","Y","","",""
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
"기획부","1","","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"업무팀","0","","","","operations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"PQ팀","0","","","","pq-team@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"재무회계팀","0","","","","finance@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"대외협력팀","0","","","","external-relations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"인사총무부","0","","","","hr-admin@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"네이버웍스관리용","1","","","","nw-admin-saman@samaneng.com","N","N","N","Y","","",""
"자산경영실","0","","","","asset-management@samaneng.com","Y","N","Y","Y","","",""
"안전품질관리실","0","","","","safety-quality@samaneng.com","Y","N","Y","Y","","",""
"사업개발실","0","","","","business-development@samaneng.com","Y","N","Y","Y","","",""
"CM본부","0","","","","cm-headquarters@samaneng.com","Y","N","Y","Y","","",""
"CM사업부","0","","","","cm-division@samaneng.com","Y","N","Y","Y","","","CM본부(cm-headquarters@samaneng.com)"
"호남지역총괄본부","0","","","","honam-headquarters@samaneng.com","Y","N","Y","Y","","","CM사업부(cm-division@samaneng.com)"
"플랜트본부","0","","","","plant-headquarters@samaneng.com","Y","N","Y","Y","","",""
"플랜트1부","0","","","","plant-1@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"플랜트2부","0","","","","plant-2@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"항만부","0","","","","harbor@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"국토개발본부","0","","","","land-development@samaneng.com","Y","N","Y","Y","","",""
"도시계획부","0","","","","urban-planning@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"도시개발부","0","","","","urban-development@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"조경레저부","0","","","","landscape-leisure@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"도로본부","0","","","","road-headquarters@samaneng.com","Y","N","Y","Y","","",""
"도로부","0","","","","road@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"지반터널부","0","","","","geotech-tunnel@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"교통계획부","0","","","","transport-planning@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"구조부","0","","","","structures@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"안전진단팀","0","","","","safety-inspection@samaneng.com","Y","N","Y","Y","","","구조부(structures@samaneng.com)"
"철도본부","0","","","","railway-headquarters@samaneng.com","Y","N","Y","Y","","",""
"철도1부","0","","","","railway-1@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"철도2부","0","","","","railway-2@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"환경평가부","0","","","","environment-assessment@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"물환경본부","0","","","","water-environment-hq@samaneng.com","Y","N","Y","Y","","",""
"물환경1부","0","","","","water-environment-1@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"물환경2부","0","","","","water-environment-2@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"물환경3부","0","","","","water-environment-3@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"수자원본부","0","","","","water-resources-hq@samaneng.com","Y","N","Y","Y","","",""
"수자원1부","0","","","","water-resources-1@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"수자원2부","0","","","","water-resources-2@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"수력부","0","","","","hydropower@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"해외사업본부","0","","","","overseas-headquarters@samaneng.com","Y","N","Y","Y","","",""
"해외사업부","0","","","","overseas-business@samaneng.com","Y","N","Y","Y","","","해외사업본부(overseas-headquarters@samaneng.com)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 기술개발센터 1 tech-dev-center@samaneng.com N N N Y
3 경영전략본부 0 business-strategy@samaneng.com Y N Y Y
4 기획부 1 planning@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
5 업무팀 0 operations@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
6 PQ팀 0 pq-team@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
7 재무회계팀 0 finance@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
8 대외협력팀 0 external-relations@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
9 인사총무부 0 hr-admin@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
10 네이버웍스관리용 1 nw-admin-saman@samaneng.com N N N Y
11 자산경영실 0 asset-management@samaneng.com Y N Y Y
12 안전품질관리실 0 safety-quality@samaneng.com Y N Y Y
13 사업개발실 0 business-development@samaneng.com Y N Y Y
14 CM본부 0 cm-headquarters@samaneng.com Y N Y Y
15 CM사업부 0 cm-division@samaneng.com Y N Y Y CM본부(cm-headquarters@samaneng.com)
16 호남지역총괄본부 0 honam-headquarters@samaneng.com Y N Y Y CM사업부(cm-division@samaneng.com)
17 플랜트본부 0 plant-headquarters@samaneng.com Y N Y Y
18 플랜트1부 0 plant-1@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
19 플랜트2부 0 plant-2@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
20 항만부 0 harbor@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
21 국토개발본부 0 land-development@samaneng.com Y N Y Y
22 도시계획부 0 urban-planning@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
23 도시개발부 0 urban-development@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
24 조경레저부 0 landscape-leisure@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
25 도로본부 0 road-headquarters@samaneng.com Y N Y Y
26 도로부 0 road@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
27 지반터널부 0 geotech-tunnel@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
28 교통계획부 0 transport-planning@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
29 구조부 0 structures@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
30 안전진단팀 0 safety-inspection@samaneng.com Y N Y Y 구조부(structures@samaneng.com)
31 철도본부 0 railway-headquarters@samaneng.com Y N Y Y
32 철도1부 0 railway-1@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
33 철도2부 0 railway-2@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
34 환경평가부 0 environment-assessment@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
35 물환경본부 0 water-environment-hq@samaneng.com Y N Y Y
36 물환경1부 0 water-environment-1@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
37 물환경2부 0 water-environment-2@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
38 물환경3부 0 water-environment-3@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
39 수자원본부 0 water-resources-hq@samaneng.com Y N Y Y
40 수자원1부 0 water-resources-1@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
41 수자원2부 0 water-resources-2@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
42 수력부 0 hydropower@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
43 해외사업본부 0 overseas-headquarters@samaneng.com Y N Y Y
44 해외사업부 0 overseas-business@samaneng.com Y N Y Y 해외사업본부(overseas-headquarters@samaneng.com)

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } from "./ParentTenantSelector";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("filterParentTenants", () => {
it("searches parent candidates by name and slug", () => {
expect(
filterParentTenants(tenants, "saman", false).map((t) => t.id),
).toEqual(["company-1"]);
expect(
filterParentTenants(tenants, "family", false).map((t) => t.id),
).toEqual(["group-1"]);
});
it("can limit parent candidates to company and company group tenants", () => {
expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
"company-1",
"group-1",
]);
});
});

View File

@@ -0,0 +1,114 @@
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type ParentTenantSelectorProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
tenants: TenantSummary[];
noneLabel: string;
helpText?: string;
excludeTenantId?: string;
};
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}
export function ParentTenantSelector({
id,
label,
value,
onChange,
tenants,
noneLabel,
helpText,
excludeTenantId,
}: ParentTenantSelectorProps) {
const [search, setSearch] = useState("");
const [companyOnly, setCompanyOnly] = useState(false);
const filteredTenants = useMemo(
() => filterParentTenants(tenants, search, companyOnly, excludeTenantId),
[tenants, search, companyOnly, excludeTenantId],
);
const selectedTenant = tenants.find((tenant) => tenant.id === value);
const optionTenants =
selectedTenant &&
!filteredTenants.some((tenant) => tenant.id === selectedTenant.id)
? [selectedTenant, ...filteredTenants]
: filteredTenants;
return (
<div className="space-y-2">
<Label htmlFor={id} className="text-sm font-semibold">
{label}
</Label>
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
<label className="relative block">
<Search
aria-hidden="true"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={`${id}-search`}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
placeholder={t(
"ui.admin.tenants.parent.search_placeholder",
"이름 또는 slug 검색",
)}
/>
</label>
<label className="flex h-9 items-center gap-2 rounded-md border border-input px-3 text-sm">
<input
type="checkbox"
checked={companyOnly}
onChange={(event) => setCompanyOnly(event.target.checked)}
className="h-4 w-4"
/>
{t("ui.admin.tenants.parent.company_only", "회사/그룹사만 표시")}
</label>
</div>
<select
id={id}
name={id}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{noneLabel}</option>
{optionTenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug}) - {tenant.type}
</option>
))}
</select>
{helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)}
</div>
);
}

View File

@@ -17,6 +17,7 @@ import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
@@ -171,25 +172,17 @@ function TenantCreatePage() {
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{parentQuery.data?.items?.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.create.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">

View File

@@ -72,7 +72,9 @@ import { isSeedTenant } from "../utils/protectedTenants";
import {
type TenantImportPreviewRow,
type TenantImportResolution,
buildTenantImportParentOptionGroups,
buildTenantImportPreview,
inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
} from "../utils/tenantCsvImport";
@@ -97,6 +99,119 @@ const getTenantIcon = (type?: string) => {
}
};
const noImportParentRef = "__none__";
function tenantParentRef(tenantId: string) {
return `tenant:${tenantId}`;
}
function previewParentRef(rowNumber: number) {
return `row:${rowNumber}`;
}
function slugParentRef(slug: string) {
return `slug:${slug}`;
}
function getImportParentGroupLabel(type: string) {
switch (type) {
case "COMPANY_GROUP":
return t(
"ui.admin.tenants.import_preview.parent_company_groups",
"기존 Company Group",
);
case "COMPANY":
return t(
"ui.admin.tenants.import_preview.parent_companies",
"기존 Company",
);
case "ORGANIZATION":
return t(
"ui.admin.tenants.import_preview.parent_organizations",
"기존 Organization",
);
default:
return type;
}
}
function resolveDefaultImportParentRef(
preview: TenantImportPreviewRow,
previewRows: TenantImportPreviewRow[],
tenants: TenantSummary[],
) {
if (preview.row.parentTenantId) {
return tenantParentRef(preview.row.parentTenantId);
}
if (!preview.row.parentTenantSlug) {
return noImportParentRef;
}
const normalizedSlug = preview.row.parentTenantSlug.toLowerCase();
const existingTenant = tenants.find(
(tenant) => tenant.slug.toLowerCase() === normalizedSlug,
);
if (existingTenant) {
return tenantParentRef(existingTenant.id);
}
const parentPreview = previewRows.find(
(candidate) =>
candidate.row.rowNumber !== preview.row.rowNumber &&
candidate.row.slug.toLowerCase() === normalizedSlug,
);
if (parentPreview) {
return previewParentRef(parentPreview.row.rowNumber);
}
return slugParentRef(preview.row.parentTenantSlug);
}
function selectedImportSlug(
preview: TenantImportPreviewRow,
selectedCreateSlugs: Record<number, string>,
) {
return (
selectedCreateSlugs[preview.row.rowNumber] || preview.defaultCreateSlug
);
}
function resolveImportParentSelection(
parentRef: string,
previewRows: TenantImportPreviewRow[],
selectedMatches: Record<number, string>,
selectedCreateSlugs: Record<number, string>,
) {
if (!parentRef || parentRef === noImportParentRef) {
return { parentTenantId: "", parentTenantSlug: "" };
}
if (parentRef.startsWith("tenant:")) {
return {
parentTenantId: parentRef.slice("tenant:".length),
parentTenantSlug: "",
};
}
if (parentRef.startsWith("slug:")) {
return { parentTenantSlug: parentRef.slice("slug:".length) };
}
if (parentRef.startsWith("row:")) {
const rowNumber = Number(parentRef.slice("row:".length));
const selected = selectedMatches[rowNumber] ?? "__create__";
if (selected && selected !== "__create__") {
return { parentTenantId: selected, parentTenantSlug: "" };
}
const parentPreview = previewRows.find(
(preview) => preview.row.rowNumber === rowNumber,
);
return {
parentTenantSlug: parentPreview
? selectedImportSlug(parentPreview, selectedCreateSlugs)
: "",
};
}
return {};
}
function TenantListPage() {
const navigate = useNavigate();
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
@@ -114,6 +229,9 @@ function TenantListPage() {
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
Record<number, string>
>({});
const [selectedParentRefs, setSelectedParentRefs] = React.useState<
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const { data: profile } = useQuery({
@@ -189,6 +307,7 @@ function TenantListPage() {
setPreviewOpen(false);
setPreviewRows([]);
setSelectedMatches({});
setSelectedParentRefs({});
query.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
@@ -234,6 +353,8 @@ function TenantListPage() {
: null;
const allTenants = query.data?.items ?? [];
const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants);
const tenants = React.useMemo(() => {
// 1. Calculate recursive counts
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
@@ -373,7 +494,9 @@ function TenantListPage() {
if (!file) return;
setImportMessage("");
const text = await file.text();
const rows = parseTenantCSV(text);
const rows = parseTenantCSV(text, {
rootParentSlug: inferTenantImportRootParentSlug(file.name, allTenants),
});
if (rows.length === 0) {
setImportMessage(
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
@@ -395,6 +518,14 @@ function TenantListPage() {
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
),
);
setSelectedParentRefs(
Object.fromEntries(
preview.map((row) => [
row.row.rowNumber,
resolveDefaultImportParentRef(row, preview, allTenants),
]),
),
);
setPreviewOpen(true);
};
@@ -406,7 +537,21 @@ function TenantListPage() {
if (selected && selected !== "__create__") {
return [
preview.row.rowNumber,
{ mode: "existing", tenantId: selected },
{
mode: "existing",
tenantId: selected,
...resolveImportParentSelection(
selectedParentRefs[preview.row.rowNumber] ??
resolveDefaultImportParentRef(
preview,
previewRows,
allTenants,
),
previewRows,
selectedMatches,
selectedCreateSlugs,
),
},
];
}
@@ -417,6 +562,17 @@ function TenantListPage() {
slug:
selectedCreateSlugs[preview.row.rowNumber] ||
preview.defaultCreateSlug,
...resolveImportParentSelection(
selectedParentRefs[preview.row.rowNumber] ??
resolveDefaultImportParentRef(
preview,
previewRows,
allTenants,
),
previewRows,
selectedMatches,
selectedCreateSlugs,
),
},
];
}),
@@ -860,6 +1016,9 @@ function TenantListPage() {
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_preview.parent", "상위")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_preview.match", "매칭")}
</TableHead>
@@ -909,6 +1068,94 @@ function TenantListPage() {
</div>
)}
</TableCell>
<TableCell>
<select
className="h-9 w-full min-w-[220px] rounded-md border border-input bg-background px-3 text-sm"
value={
selectedParentRefs[preview.row.rowNumber] ??
resolveDefaultImportParentRef(
preview,
previewRows,
allTenants,
)
}
data-testid={`tenant-import-parent-select-${preview.row.rowNumber}`}
onChange={(event) =>
setSelectedParentRefs((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value={noImportParentRef}>
{t("ui.common.none", "없음")}
</option>
{importParentOptionGroups.map((group) => (
<optgroup
key={group.type}
label={getImportParentGroupLabel(group.type)}
>
{group.tenants.map((tenant) => (
<option
key={tenant.id}
value={tenantParentRef(tenant.id)}
>
{tenant.name} ({tenant.slug}) - {tenant.type}
</option>
))}
</optgroup>
))}
<optgroup
label={t(
"ui.admin.tenants.import_preview.csv_parents",
"가져오기 CSV",
)}
>
{previewRows
.filter(
(candidate) =>
candidate.row.rowNumber !==
preview.row.rowNumber,
)
.map((candidate) => (
<option
key={candidate.row.rowNumber}
value={previewParentRef(
candidate.row.rowNumber,
)}
>
{candidate.row.name} (
{selectedImportSlug(
candidate,
selectedCreateSlugs,
)}
)
</option>
))}
</optgroup>
{(
selectedParentRefs[preview.row.rowNumber] ??
resolveDefaultImportParentRef(
preview,
previewRows,
allTenants,
)
).startsWith("slug:") && (
<option
value={
selectedParentRefs[preview.row.rowNumber] ??
resolveDefaultImportParentRef(
preview,
previewRows,
allTenants,
)
}
>
{preview.row.parentTenantSlug}
</option>
)}
</select>
</TableCell>
<TableCell>
<div className="space-y-2">
<select

View File

@@ -24,10 +24,20 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
} from "../utils/domainTags";
import {
ORG_UNIT_TYPE_OPTIONS,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
@@ -51,9 +61,6 @@ export function TenantProfilePage() {
queryFn: () => fetchTenants(1000, 0),
});
const availableParents =
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
@@ -64,9 +71,13 @@ export function TenantProfilePage() {
[],
);
const [parentId, setParentId] = useState("");
const [orgUnitType, setOrgUnitType] = useState("");
const [tenantVisibility, setTenantVisibility] =
useState<TenantVisibility>("public");
useEffect(() => {
if (tenantQuery.data) {
const orgConfig = readTenantOrgConfig(tenantQuery.data.config);
setName(tenantQuery.data.name);
setType(tenantQuery.data.type || "COMPANY");
setSlug(tenantQuery.data.slug);
@@ -75,12 +86,37 @@ export function TenantProfilePage() {
setDomains(tenantQuery.data.domains ?? []);
setForceDomainConflicts([]);
setParentId(tenantQuery.data.parentId ?? "");
setOrgUnitType(orgConfig.orgUnitType);
setTenantVisibility(orgConfig.visibility);
}
}, [tenantQuery.data]);
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data
? {
...tenantQuery.data,
parentId: parentId || undefined,
slug,
}
: undefined;
const canEditOrgConfig = orgConfigCandidate
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants,
orgConfigCandidate,
])
: false;
const updateMutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) =>
updateTenant(tenantId, {
mutationFn: (overrideForceDomains?: string[]) => {
const baseConfig = tenantQuery.data?.config;
const config = canEditOrgConfig
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
})
: removeTenantOrgConfig(baseConfig);
return updateTenant(tenantId, {
name,
type,
slug,
@@ -89,7 +125,9 @@ export function TenantProfilePage() {
parentId: parentId || undefined,
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
config,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
@@ -250,31 +288,22 @@ export function TenantProfilePage() {
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
{availableParents.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
</p>
</div>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.profile.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
helpText={t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
excludeTenantId={tenantId}
/>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
@@ -336,6 +365,45 @@ export function TenantProfilePage() {
</Button>
</div>
</div>
{canEditOrgConfig && (
<div className="grid gap-4 rounded-md border border-border/70 p-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.org_unit_type", "조직 세부타입")}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tenantVisibility}
onChange={(event) =>
setTenantVisibility(event.target.value as TenantVisibility)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
)}
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
mergeTenantOrgConfig,
readTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("tenant org config", () => {
it("allows org config only for hanmac-family descendants", () => {
const family = tenant(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
);
const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
const tenants = [family, saman, team, outsider];
expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
});
it("reads and writes tenant visibility and org unit type", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" });
expect(
mergeTenantOrgConfig(
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
{ orgUnitType: "", visibility: "internal" },
),
).toEqual({ userSchema: [], visibility: "internal" });
});
});

View File

@@ -0,0 +1,92 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const ORG_UNIT_TYPE_OPTIONS = [
"실",
"팀",
"디비전",
"셀",
"본부",
"지역본부",
"부",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [
{ label: "공개", value: "public" },
{ label: "내부", value: "internal" },
{ label: "비공개", value: "private" },
] as const;
export type TenantVisibility =
(typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
export type TenantOrgConfig = {
orgUnitType: string;
visibility: TenantVisibility;
};
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
const TENANT_VISIBILITY_SET = new Set<string>(
TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
);
export function shouldAllowHanmacOrgConfig(
tenant: Pick<TenantSummary, "id" | "parentId" | "slug">,
tenants: Array<Pick<TenantSummary, "id" | "parentId" | "slug">>,
) {
if (tenant.slug.toLowerCase() === "hanmac-family") return false;
const byId = new Map(tenants.map((item) => [item.id, item]));
let parentId = tenant.parentId;
const visited = new Set<string>();
while (parentId) {
if (visited.has(parentId)) return false;
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) return false;
if (parent.slug.toLowerCase() === "hanmac-family") return true;
parentId = parent.parentId;
}
return false;
}
export function readTenantOrgConfig(
config: Record<string, unknown> | undefined,
): TenantOrgConfig {
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
const rawOrgUnitType = String(config?.orgUnitType ?? "");
return {
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
? (rawVisibility as TenantVisibility)
: "public",
};
}
export function mergeTenantOrgConfig(
config: Record<string, unknown> | undefined,
next: TenantOrgConfig,
) {
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
const merged = { ...rest };
merged.visibility = next.visibility;
if (next.orgUnitType) {
merged.orgUnitType = next.orgUnitType;
}
return merged;
}
export function removeTenantOrgConfig(
config: Record<string, unknown> | undefined,
) {
const {
orgUnitType: _orgUnitType,
visibility: _visibility,
...rest
} = config ?? {};
return rest;
}

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
buildTenantImportParentOptionGroups,
buildTenantImportPreview,
inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
} from "./tenantCsvImport";
@@ -31,9 +33,37 @@ const tenants: TenantSummary[] = [
createdAt: "",
updatedAt: "",
},
{
id: "tenant-3",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-4",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("tenantCsvImport", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
@@ -87,7 +117,7 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
@@ -110,7 +140,7 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
"staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
"staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
);
expect(csv).not.toContain("local-tenant-id");
});
@@ -138,10 +168,10 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
@@ -171,7 +201,157 @@ describe("tenantCsvImport", () => {
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("parses Naver Works organization CSV columns into tenant import rows", () => {
const rows = parseTenantCSV(
[
'"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
'"기술개발센터","1","","","","tdc@samaneng.com",""',
'"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
'"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
].join("\n"),
{ rootParentSlug: "saman" },
);
expect(rows).toMatchObject([
{
name: "기술개발센터",
type: "ORGANIZATION",
slug: "tdc",
parentTenantSlug: "saman",
},
{
name: "기획부",
type: "ORGANIZATION",
slug: "planning",
parentTenantSlug: "tdc",
},
{
name: "업무팀",
type: "ORGANIZATION",
slug: "t-226wn",
parentTenantSlug: "planning",
},
]);
});
it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
"saman",
);
expect(
inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
).toBe("hanmac-family");
expect(
inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
).toBe("saman");
expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
"",
);
expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
"",
);
});
it("groups existing parent candidates by company group, company, and organization", () => {
const groups = buildTenantImportParentOptionGroups(tenants);
expect(groups.map((group) => group.type)).toEqual([
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
]);
expect(
groups.map((group) => group.tenants.map((tenant) => tenant.id)),
).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
});
it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
const randomUUID = vi
.fn()
.mockReturnValueOnce("parent-generated-id")
.mockReturnValueOnce("child-generated-id");
vi.stubGlobal("crypto", { randomUUID });
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: { mode: "create", slug: "tech-center" },
3: { mode: "create", slug: "structure-div" },
});
expect(csv).toContain(
"parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
);
expect(csv).toContain(
"child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
);
});
it("serializes explicit parent tenant selections from the import preview", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
slug: "tech-center",
parentTenantId: "tenant-2",
parentTenantSlug: "",
},
3: {
mode: "create",
slug: "structure-div",
parentTenantSlug: "tech-center",
},
});
expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
expect(csv).toContain(",tech-center,structure-div,,");
});
});

View File

@@ -12,6 +12,12 @@ export type TenantCSVRow = {
emailDomain: string;
};
export type TenantCSVParseOptions = {
rootParentSlug?: string;
};
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
export type TenantImportCandidate = {
tenantId: string;
name: string;
@@ -28,6 +34,16 @@ export type TenantImportPreviewRow = {
conflicts: TenantImportConflict[];
};
export type TenantImportParentOptionGroupType =
| "COMPANY_GROUP"
| "COMPANY"
| "ORGANIZATION";
export type TenantImportParentOptionGroup = {
type: TenantImportParentOptionGroupType;
tenants: TenantSummary[];
};
export type TenantImportConflict =
| "external_tenant_id"
| "slug_exists"
@@ -37,12 +53,15 @@ export type TenantImportResolution =
| {
mode: "existing";
tenantId: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "create";
tenantId?: string;
slug?: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "skip";
@@ -53,16 +72,18 @@ const importHeaders = [
"name",
"type",
"parent_tenant_id",
"parent_tenant_slug",
"slug",
"memo",
"email_domain",
];
const headerAliases: Record<string, keyof TenantCSVRow> = {
const headerAliases: Record<string, TenantCSVSourceKey> = {
id: "tenantId",
tenantid: "tenantId",
tenant_id: "tenantId",
name: "name",
: "name",
type: "type",
parentid: "parentTenantId",
parent_id: "parentTenantId",
@@ -70,9 +91,12 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
_조직: "parentOrg",
slug: "slug",
memo: "memo",
description: "memo",
: "memo",
_리스트: "mailingList",
"email-domain": "emailDomain",
emaildomain: "emailDomain",
email_domain: "emailDomain",
@@ -80,39 +104,96 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
domains: "emailDomain",
};
export function parseTenantCSV(text: string): TenantCSVRow[] {
export function parseTenantCSV(
text: string,
options: TenantCSVParseOptions = {},
): TenantCSVRow[] {
const records = parseCSV(text.replace(/^\uFEFF/, ""));
if (records.length === 0) return [];
const header = new Map<keyof TenantCSVRow, number>();
const header = new Map<TenantCSVSourceKey, number>();
records[0].forEach((column, index) => {
const normalized = normalizeHeader(column);
const key = headerAliases[normalized];
if (key) header.set(key, index);
});
return records.slice(1).flatMap((record, index) => {
const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
const sourceRows = records.slice(1).flatMap((record, index) => {
if (record.every((value) => value.trim() === "")) return [];
const value = (key: keyof TenantCSVRow) => {
const value = (key: TenantCSVSourceKey) => {
const columnIndex = header.get(key);
if (columnIndex === undefined) return "";
return (record[columnIndex] ?? "").trim();
};
return {
raw: record,
rowNumber: index + 2,
tenantId: value("tenantId"),
name: value("name"),
type: value("type"),
slug: value("slug") || slugFromMailingList(value("mailingList")),
mailingList: value("mailingList"),
parentOrg: value("parentOrg"),
value,
};
});
const slugByName = new Map(
sourceRows
.filter((row) => row.name && row.slug)
.map((row) => [row.name, row.slug] as const),
);
return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
const parentTenantSlug =
value("parentTenantSlug") ||
slugFromParentOrg(parentOrg, slugByName) ||
(isOrgChartCSV ? options.rootParentSlug || "" : "");
return {
rowNumber,
tenantId: value("tenantId"),
name,
type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
parentTenantId: value("parentTenantId"),
parentTenantSlug: value("parentTenantSlug"),
slug: value("slug"),
parentTenantSlug,
slug,
memo: value("memo"),
emailDomain: value("emailDomain"),
};
});
}
export function inferTenantImportRootParentSlug(
fileName: string,
tenants: TenantSummary[] = [],
) {
const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
const [prefix = ""] = baseName.split("_");
if (!prefix) return "";
const existingTenant = tenants.find(
(tenant) => tenant.slug.toLowerCase() === prefix,
);
return existingTenant ? prefix : "";
}
export function buildTenantImportParentOptionGroups(
tenants: TenantSummary[],
): TenantImportParentOptionGroup[] {
const orderedTypes: TenantImportParentOptionGroupType[] = [
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
];
return orderedTypes
.map((type) => ({
type,
tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
}))
.filter((group) => group.tenants.length > 0);
}
export function buildTenantImportPreview(
rows: TenantCSVRow[],
tenants: TenantSummary[],
@@ -169,27 +250,40 @@ export function serializeTenantImportCSV(
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
const hasParentTenantIdOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantId");
const hasParentTenantSlugOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantSlug");
const sourceParentTenantSlug = hasParentTenantSlugOverride
? resolution.parentTenantSlug || ""
: preview.row.parentTenantSlug;
const parentTenantId =
typeof resolution === "object" && resolution.mode === "create"
? (resolution.parentTenantId ??
remapParentTenantId(
preview.row.parentTenantId,
preview.row.parentTenantSlug,
targetTenantIds,
))
typeof resolution === "object"
? hasParentTenantIdOverride
? resolution.parentTenantId || ""
: remapParentTenantId(
preview.row.parentTenantId,
sourceParentTenantSlug,
targetTenantIds,
)
: preview.row.parentTenantId;
const parentTenantSlug = remapParentTenantSlug(
sourceParentTenantSlug,
targetTenantIds,
);
const tenantId =
typeof resolution === "object" && resolution.mode === "create"
? (resolution.tenantId ??
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
createTenantImportId())
: selectedTenantId || preview.row.tenantId;
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
selectedTenantId ??
preview.row.tenantId;
lines.push([
tenantId,
preview.row.name,
preview.row.type,
parentTenantId,
parentTenantSlug,
slug,
preview.row.memo,
preview.row.emailDomain,
@@ -202,8 +296,10 @@ function buildTargetTenantIds(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const byRowNumber = new Map<number, string>();
const bySourceId = new Map<string, string>();
const bySourceSlug = new Map<string, string>();
const bySourceSlugToTargetSlug = new Map<string, string>();
for (const preview of previewRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
@@ -217,24 +313,38 @@ function buildTargetTenantIds(
: resolution.mode === "existing"
? resolution.tenantId
: resolution.tenantId || createTenantImportId();
const targetSlug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
if (targetTenantId) {
byRowNumber.set(preview.row.rowNumber, targetTenantId);
}
if (preview.row.tenantId) {
bySourceId.set(preview.row.tenantId, targetTenantId);
}
if (preview.row.slug) {
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
}
if (targetSlug) {
bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
}
}
return { bySourceId, bySourceSlug };
return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
}
function remapParentTenantId(
parentTenantId: string,
parentTenantSlug: string,
targetTenantIds: {
byRowNumber: Map<number, string>;
bySourceId: Map<string, string>;
bySourceSlug: Map<string, string>;
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (parentTenantId) {
@@ -248,6 +358,20 @@ function remapParentTenantId(
return "";
}
function remapParentTenantSlug(
parentTenantSlug: string,
targetTenantIds: {
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (!parentTenantSlug) return "";
return (
targetTenantIds.bySourceSlugToTargetSlug.get(
parentTenantSlug.toLowerCase(),
) ?? parentTenantSlug
);
}
function createTenantImportId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
@@ -377,6 +501,33 @@ function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function slugFromMailingList(value: string) {
if (!value) return "";
return normalizeTenantSlug(value.split("@")[0] ?? value);
}
function slugFromParentOrg(value: string, slugByName: Map<string, string>) {
const trimmed = value.trim();
if (!trimmed) return "";
const match = trimmed.match(/\(([^)]+)\)/);
if (match?.[1]) {
return slugFromMailingList(match[1]);
}
return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
}
function normalizeTenantSlug(value: string) {
let slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-");
slug = slug.replace(/^-+|-+$/g, "");
if (slug.length > 25) {
slug = slug.slice(0, 25).replace(/-+$/g, "");
}
return slug;
}
function normalizeToken(value: string) {
return value
.trim()

View File

@@ -98,6 +98,7 @@ function createEmptyAppointment(): AppointmentDraft {
tenantName: "",
tenantSlug: "",
isOwner: false,
grade: "",
jobTitle: "",
position: "",
};
@@ -148,6 +149,7 @@ function UserCreatePage() {
phone: "",
tenantSlug: searchParams.get("tenantSlug") || "",
department: "",
grade: "",
position: "",
jobTitle: "",
metadata: {},
@@ -379,6 +381,7 @@ function UserCreatePage() {
}
payload.tenantSlug = data.tenantSlug;
payload.department = data.department;
payload.grade = data.grade;
payload.position = data.position;
payload.jobTitle = data.jobTitle;
}
@@ -411,6 +414,7 @@ function UserCreatePage() {
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
@@ -685,12 +689,20 @@ function UserCreatePage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="position"></Label>
<Label htmlFor="grade"></Label>
<Input
id="grade"
placeholder="수석/책임/선임"
{...register("grade")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="position"></Label>
<Input
id="position"
placeholder="수석/책임/선임"
placeholder="팀장/센터장"
{...register("position")}
/>
</div>
@@ -709,9 +721,11 @@ function UserCreatePage() {
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium"> /</p>
<p className="text-sm font-medium">
//
</p>
<p className="text-xs text-muted-foreground">
, , .
, , , .
</p>
</div>
<Button
@@ -778,9 +792,23 @@ function UserCreatePage() {
</div>
<div
className="grid gap-3 sm:grid-cols-2"
className="grid gap-3 sm:grid-cols-3"
data-testid={`appointment-position-line-${index}`}
>
<div className="space-y-2">
<Label htmlFor={`appointment-grade-${index}`}>
</Label>
<Input
id={`appointment-grade-${index}`}
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`appointment-job-title-${index}`}>
@@ -797,7 +825,7 @@ function UserCreatePage() {
</div>
<div className="space-y-2">
<Label htmlFor={`appointment-position-${index}`}>
</Label>
<Input
id={`appointment-position-${index}`}

View File

@@ -125,6 +125,7 @@ function createEmptyAppointment(): AppointmentDraft {
tenantSlug: "",
isPrimary: false,
isOwner: false,
grade: "",
jobTitle: "",
position: "",
};
@@ -379,6 +380,7 @@ function UserDetailPage() {
status: "active",
tenantSlug: "",
department: "",
grade: "",
position: "",
jobTitle: "",
metadata: {},
@@ -622,6 +624,7 @@ function UserDetailPage() {
)?.slug ||
"",
department: user.department || "",
grade: user.grade || "",
position: user.position || "",
jobTitle: user.jobTitle || "",
metadata:
@@ -671,6 +674,7 @@ function UserDetailPage() {
isOwner:
metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id,
grade: user.grade,
jobTitle: user.jobTitle,
position: user.position,
}))
@@ -683,6 +687,7 @@ function UserDetailPage() {
tenantSlug: fallbackAppointment.slug,
isPrimary: true,
isOwner: metadata.primaryTenantIsOwner === true,
grade: user.grade,
jobTitle: user.jobTitle,
position: user.position,
},
@@ -750,6 +755,7 @@ function UserDetailPage() {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
payload.jobTitle = undefined;
payload.metadata = {
@@ -771,6 +777,7 @@ function UserDetailPage() {
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
@@ -790,6 +797,7 @@ function UserDetailPage() {
}
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
payload.jobTitle = undefined;
payload.additionalAppointments = appointments;
@@ -1142,13 +1150,13 @@ function UserDetailPage() {
<p className="text-sm font-medium">
{t(
"ui.admin.users.detail.form.additional_appointments",
"소속별 직급/직무",
"소속별 직급/직책/직무",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.form.additional_appointments_help",
"테넌트별 조직장 여부, 직, 직급을 입력합니다.",
"테넌트별 조직장 여부, 직, 직책, 직무를 입력합니다.",
)}
</p>
</div>
@@ -1226,9 +1234,28 @@ function UserDetailPage() {
</div>
<div
className="grid gap-3 sm:grid-cols-2"
className="grid gap-3 sm:grid-cols-3"
data-testid={`detail-appointment-position-line-${index}`}
>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-grade-${index}`}
>
{t(
"ui.admin.users.detail.form.grade",
"직급",
)}
</Label>
<Input
id={`detail-appointment-grade-${index}`}
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-job-title-${index}`}
@@ -1255,7 +1282,7 @@ function UserDetailPage() {
>
{t(
"ui.admin.users.detail.form.position",
"직",
"직",
)}
</Label>
<Input
@@ -1313,12 +1340,25 @@ function UserDetailPage() {
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="grade"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.grade", "직급")}
</Label>
<Input
id="grade"
{...register("grade")}
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="position"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.position", "직")}
{t("ui.admin.users.detail.form.position", "직")}
</Label>
<Input
id="position"

View File

@@ -249,9 +249,9 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const downloadTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,position,jobTitle,employee_id";
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});

View File

@@ -57,6 +57,7 @@ test@test.com,Test,baron`;
name: "John Doe",
phone: "+19144812222",
department: "myteam",
grade: "Manager",
position: "Manager",
jobTitle: "Sales management",
tenantImport: {

View File

@@ -79,6 +79,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
};
} else if (header === "department") {
item.department = value;
} else if (header === "grade") {
item.grade = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
@@ -100,6 +102,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
} else if (header === "usertype") {
item.metadata.naverworks_user_type = value;
} else if (header === "level") {
item.grade = value;
item.metadata.naverworks_level = value;
} else if (header === "organization") {
item.metadata.naverworks_organization_path = value;
@@ -247,7 +250,7 @@ function applyNaverWorksFallbacks(
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
if (!item.position && item.metadata.naverworks_level) {
item.position = item.metadata.naverworks_level;
if (!item.grade && item.metadata.naverworks_level) {
item.grade = item.metadata.naverworks_level;
}
}

View File

@@ -477,6 +477,7 @@ export type UserSummary = {
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
@@ -499,6 +500,7 @@ export type UserCreateRequest = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
@@ -523,6 +525,7 @@ export type UserUpdateRequest = {
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
@@ -539,6 +542,7 @@ export type UserAppointment = {
isPrimary?: boolean;
isOwner: boolean;
jobTitle?: string;
grade?: string;
position?: string;
};
@@ -550,6 +554,7 @@ export type BulkUserItem = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
tenantImport?: {
@@ -786,6 +791,7 @@ export async function bulkUpdateUsers(payload: {
tenantSlug?: string;
department?: string;
position?: string;
grade?: string;
jobTitle?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {

View File

@@ -1118,6 +1118,8 @@ email = "Email"
email_placeholder = "user@example.com"
job_title = "Job Title"
job_title_placeholder = "e.g. Frontend Developer"
grade = "Grade"
grade_placeholder = "e.g. Senior"
name = "Name"
name_placeholder = "Name Placeholder"
password = "Password"
@@ -1125,7 +1127,7 @@ password_placeholder = "********"
phone = "Phone number"
phone_placeholder = "010-1234-5678"
position = "Position"
position_placeholder = "e.g. Senior"
position_placeholder = "e.g. Team Lead"
role = "Role"
tenant = "Tenant"
tenant_global = "Tenant Global"
@@ -1147,6 +1149,8 @@ multi_title = "Per-tenant Profile Management"
[ui.admin.users.detail.form]
department = "Department"
department_placeholder = "Department Placeholder"
grade = "Grade"
grade_placeholder = "e.g. Senior"
name = "Name"
name_placeholder = "Name Placeholder"
phone = "Phone number"
@@ -1155,6 +1159,8 @@ role = "Role"
status = "Status"
tenant = "Representative Affiliated Tenant"
tenant_global = "Tenant Global"
position = "Position"
position_placeholder = "e.g. Team Lead"
[ui.admin.users.detail.security]
password = "Password"

View File

@@ -1120,14 +1120,16 @@ email = "이메일"
email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직"
position_placeholder = "수석/책임/선임"
position = "직"
position_placeholder = "팀장/센터장"
role = "역할"
tenant = "테넌트"
tenant_global = "시스템 전역"
@@ -1149,6 +1151,8 @@ multi_title = "테넌트별 프로필 관리"
[ui.admin.users.detail.form]
department = "부서"
department_placeholder = "개발팀"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
phone = "전화번호"
@@ -1157,6 +1161,8 @@ role = "역할"
status = "상태"
tenant = "대표 소속 테넌트"
tenant_global = "시스템 전역"
position = "직책"
position_placeholder = "팀장/센터장"
[ui.admin.users.detail.security]
password = "비밀번호 변경"

View File

@@ -569,6 +569,7 @@ test.describe("User Management", () => {
await page.getByRole("switch", { name: /대표 조직/i }).click();
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
await page.getByLabel(/^직급$/i).fill("책임");
await page.getByLabel(/^직책$/i).fill("팀장");
await page.locator('input[name="name"]').fill("Family User");
await page.locator('input[name="email"]').fill("family@test.com");
@@ -585,8 +586,9 @@ test.describe("User Management", () => {
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
grade: "책임",
jobTitle: "플랫폼 운영",
position: "책임",
position: "팀장",
},
],
},
@@ -705,8 +707,9 @@ test.describe("User Management", () => {
tenantName: "기술기획",
isPrimary: true,
isOwner: true,
grade: "책임",
jobTitle: "플랫폼 운영",
position: "책임",
position: "팀장",
},
],
},
@@ -767,16 +770,18 @@ test.describe("User Management", () => {
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
grade: "책임",
jobTitle: "플랫폼 운영",
position: "책임",
position: "팀장",
},
{
tenantId: "hanmac-team-id",
tenantSlug: "hanmac-team",
tenantName: "한맥팀",
isOwner: false,
grade: "선임",
jobTitle: "개발",
position: "선임",
position: "파트장",
},
],
},

View File

@@ -3,6 +3,9 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
esbuild: {
jsx: "automatic",
},
test: {
globals: true,
environment: "jsdom",

View File

@@ -26,14 +26,12 @@ func main() {
norm := domain.NormalizeRole(r)
if norm != r && norm == domain.RoleUser {
traits["role"] = norm
traits["grade"] = norm
changed = true
}
} else if g, ok := traits["grade"].(string); ok {
norm := domain.NormalizeRole(g)
if norm != g && norm == domain.RoleUser {
if norm, ok := domain.NormalizeRoleAlias(g); ok {
traits["role"] = norm
traits["grade"] = norm
delete(traits, "grade")
changed = true
}
}

View File

@@ -587,6 +587,10 @@ func main() {
"checks": checks,
})
})
rpManifestHandler := handler.NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest", rpManifestHandler.GetHTML)
app.Get("/.well-known/baron-rp-manifest.json", rpManifestHandler.GetJSON)
app.Get("/.well-known/baron-rp-manifest.schema.json", rpManifestHandler.GetSchema)
// API Group
api := app.Group("/api/v1")

View File

@@ -140,7 +140,7 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
"department": "Admin",
"affiliationType": "internal",
"companyCode": "",
"grade": "admin",
"grade": "",
"role": domain.RoleSuperAdmin,
},
}

View File

@@ -35,7 +35,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
"department": "Admin",
"affiliationType": "internal",
"companyCode": "",
"grade": "admin",
"grade": "",
"role": "super_admin", // Explicitly set role for Kratos traits
},
}

View File

@@ -28,20 +28,25 @@ const (
// NormalizeRole maps legacy/synonym role values to canonical role keys.
func NormalizeRole(role string) string {
if normalized, ok := NormalizeRoleAlias(role); ok {
return normalized
}
return RoleUser
}
func NormalizeRoleAlias(role string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(role))
switch normalized {
case RoleSuperAdmin, RoleTenantAdmin, RoleRPAdmin, RoleUser:
return normalized
return normalized, true
case "tenant_member", "member":
return RoleUser
return RoleUser, true
case "admin", "tenantadmin", "tenant-admin":
return RoleTenantAdmin
return RoleTenantAdmin, true
case "superadmin", "super-admin":
return RoleSuperAdmin
return RoleSuperAdmin, true
default:
// Default any other business title (팀장, 그룹장, etc.) to a regular user.
// These should be mapped to JobTitle or Position instead.
return RoleUser
return "", false
}
}
@@ -60,7 +65,8 @@ type User struct {
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
Department string `gorm:"column:department" json:"department"`
Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임)
Grade string `gorm:"column:grade" json:"grade"` // 직급 (예: 수석, 책임, 선임)
Position string `gorm:"column:position" json:"position"` // 직책 (예: 팀장, 센터장)
JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
Status string `gorm:"column:status;default:'active'" json:"status"`

View File

@@ -782,8 +782,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
"department": req.Department,
"affiliationType": req.AffiliationType,
"companyCode": companyCode,
// grade는 기존 스키마 필수 키이므로 기본값을 설정
"grade": "member",
"grade": "",
"role": domain.RoleUser,
}
// Sync all custom login IDs based on tenant schemas
@@ -7275,6 +7275,11 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
if department := extractTraitString(traits, "department"); department != "" {
localUser.Department = department
}
if grade := extractTraitString(traits, "grade"); grade != "" {
if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
localUser.Grade = grade
}
}
if position := extractTraitString(traits, "position"); position != "" {
localUser.Position = position
}
@@ -7302,13 +7307,12 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
localUser.RelyingPartyID = &relyingPartyID
}
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role"))
if !ok {
role, ok = domain.NormalizeRoleAlias(extractTraitString(traits, "grade"))
if !ok {
role = domain.RoleUser
}
}
localUser.Role = role
if localUser.Status == "" {

View File

@@ -0,0 +1,255 @@
package handler
import (
"html"
"os"
"strings"
"github.com/gofiber/fiber/v2"
)
type RPManifestHandler struct{}
const rpObjectLookupMermaid = `flowchart TD
A[RP request] --> B{obj_id supplied?}
B -->|yes| C[Normalize object type and obj_id]
B -->|no| D{Route has client_id?}
D -->|yes| E[obj_id = RelyingParty:<client_id>]
D -->|no| F{Route has tenant_id?}
F -->|yes| G[obj_id = Tenant:<tenant_id>]
F -->|no| H[Reject: explicit obj_id required]
C --> I[Check Keto relation]
E --> I
G --> I
I --> J{allowed?}
J -->|yes| K[Inject trusted Baron headers]
J -->|no| L[Reject request]
K --> M[Write audit with obj_id, relation, client_id, X-Request-Id]`
const rpExternalKeyMermaid = `flowchart TD
A[User authenticates through Baron SSO] --> B[Baron resolves internal identity]
B --> C[Baron derives or loads Baron-issued alias]
C --> D[Baron injects X-Baron-External-Key]
D --> E[Baron injects X-Baron-Subject]
E --> I[RP receives trusted headers from Baron gateway]
I --> F[RP upserts local user with provider + X-Baron-External-Key]
F --> G[RP stores the full external key as opaque value]
G --> H[RP never parses or stores raw kratos_identity_id]`
func NewRPManifestHandler() *RPManifestHandler {
return &RPManifestHandler{}
}
func (h *RPManifestHandler) GetJSON(c *fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=300")
return c.JSON(buildRPManifest(c))
}
func (h *RPManifestHandler) GetSchema(c *fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=300")
return c.JSON(rpManifestSchema())
}
func (h *RPManifestHandler) GetHTML(c *fiber.Ctx) error {
manifest := buildRPManifest(c)
issuer, _ := manifest["issuer"].(string)
c.Set(fiber.HeaderCacheControl, "public, max-age=300")
c.Type("html", "utf-8")
return c.SendString(`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Baron RP IAM Manifest</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.6; max-width: 920px; }
code, pre { background: #f5f5f5; border-radius: 4px; padding: .1rem .3rem; }
pre { padding: 1rem; overflow: auto; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: .5rem; text-align: left; }
</style>
</head>
<body>
<h1>Baron RP IAM Manifest</h1>
<p>외부 RP가 Baron SSO/Ory Stack/Keto 기반 공용 IAM을 연동하기 위한 공개 규격입니다.</p>
<ul>
<li>Machine-readable manifest: <a href="/.well-known/baron-rp-manifest.json">/.well-known/baron-rp-manifest.json</a></li>
<li>JSON schema: <a href="/.well-known/baron-rp-manifest.schema.json">/.well-known/baron-rp-manifest.schema.json</a></li>
</ul>
<h2>Issuer</h2>
<pre>` + html.EscapeString(issuer) + `</pre>
<h2>Identity Contract</h2>
<table>
<tr><th>용도</th><th>Header</th><th>정책</th></tr>
<tr><td>Keto subject</td><td><code>X-Baron-Subject</code></td><td><code>User:&lt;baron_identity_id&gt;</code> 전체 문자열을 opaque subject로 취급합니다.</td></tr>
<tr><td>RP upsert key</td><td><code>X-Baron-External-Key</code></td><td>Baron-issued alias입니다. RP가 만들거나 제출하지 않고, Baron이 주입한 전체 문자열을 local user external key로 저장합니다.</td></tr>
<tr><td>RP client</td><td><code>X-Baron-Client-ID</code></td><td>현재 접근 중인 RP client id입니다.</td></tr>
</table>
<h2>External Key Flow</h2>
<p><code>X-Baron-External-Key</code>는 RP 입력값이 아니라 Baron이 인증된 subject에서 발급/조회해 주입하는 opaque alias입니다. RP upserts local user from the Baron-issued alias.</p>
<pre>` + "```mermaid\n" + html.EscapeString(rpExternalKeyMermaid) + "\n```" + `</pre>
<h2>Object Lookup</h2>
<pre>check(User:abc, viewers, RelyingParty:&lt;client_id&gt;)
check(User:abc, members, Tenant:&lt;tenant_id&gt;)
check(User:abc, viewers, Resource:&lt;resource_type&gt;:&lt;resource_id&gt;)</pre>
<h2>audit_contract</h2>
<p>권한과 설정을 변경하는 command는 sync audit write에 실패하면 요청도 실패해야 합니다. Read audit은 allowlist된 조회에 한해 best effort로 취급합니다.</p>
<pre>{
"mutating_command_mode": "fail_closed_sync",
"missing_audit_sink_behavior": "reject_mutation",
"correlation_header": "X-Request-Id"
}</pre>
<h2>Object Lookup Flow</h2>
<pre>` + "```mermaid\n" + html.EscapeString(rpObjectLookupMermaid) + "\n```" + `</pre>
</body>
</html>`)
}
func buildRPManifest(c *fiber.Ctx) map[string]any {
issuer := resolvePublicRequestBaseURL(c, os.Getenv("BACKEND_PUBLIC_URL"))
if issuer == "" {
issuer = strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
}
if issuer == "" {
issuer = "https://sso.hmac.kr"
}
issuer = strings.TrimRight(issuer, "/")
return map[string]any{
"version": "2026-05-11",
"issuer": issuer,
"oidc": map[string]any{
"discovery_url": issuer + "/.well-known/openid-configuration",
"jwks_url": issuer + "/.well-known/jwks.json",
"supported_flows": []string{"authorization_code_pkce"},
"required_scopes": []string{"openid", "profile", "email"},
},
"iam": map[string]any{
"authorization_engine": "ory-keto",
"subject_format": "User:<baron_identity_id>",
"target_object_patterns": []string{
"RelyingParty:<client_id>",
"Tenant:<tenant_id>",
"Resource:<resource_type>:<resource_id>",
},
"supported_relations": []string{
"admins",
"users",
"viewers",
"operators",
"members",
"owners",
"editors",
},
},
"identity_contract": map[string]any{
"subject_header": "X-Baron-Subject",
"external_key_header": "X-Baron-External-Key",
"external_key_is_opaque": true,
"external_key_issuer": "baron",
"external_key_delivery": "baron_injected_header",
"external_key_lifecycle": "issued_or_loaded_after_successful_authentication_before_rp_request",
"rp_supplied_external_key_allowed": false,
"rp_user_upsert_source": "rp_must_upsert_from_header_value",
"raw_kratos_identity_id_exposed": false,
"rp_user_upsert_key": "provider + external_key",
"email_is_stable_primary_key": false,
"initial_external_key_expression": "X-Baron-External-Key",
"fallback_to_subject_allowed": false,
},
"trusted_headers": map[string]any{
"subject": "X-Baron-Subject",
"external_key": "X-Baron-External-Key",
"email": "X-Baron-Email",
"tenant": "X-Baron-Tenant",
"relations": "X-Baron-Relations",
"client_id": "X-Baron-Client-ID",
},
"object_lookup": map[string]any{
"rp_level": map[string]any{
"object": "RelyingParty:<client_id>",
"relations": []string{"viewers", "users", "operators", "admins"},
"example": "check(User:abc, viewers, RelyingParty:mh-dashboard)",
},
"tenant_level": map[string]any{
"object": "Tenant:<tenant_id>",
"relations": []string{"members", "admins", "owners"},
"example": "check(User:abc, members, Tenant:9caf62e1-297d-4e8f-870b-61780998bbe)",
},
"resource_level": map[string]any{
"object": "Resource:<resource_type>:<resource_id>",
"relations": []string{"viewers", "editors", "owners"},
"example": "check(User:abc, viewers, Resource:dashboard:mh-monthly-2026-05)",
},
"recommended_order": []string{
"authenticated",
"rp_level",
"tenant_or_resource_level",
"trusted_header_injection",
},
},
"object_lookup_flow": map[string]any{
"format": "mermaid",
"mermaid": rpObjectLookupMermaid,
},
"external_key_flow": map[string]any{
"format": "mermaid",
"mermaid": rpExternalKeyMermaid,
},
"audit_contract": map[string]any{
"mutating_command_mode": "fail_closed_sync",
"missing_audit_sink_behavior": "reject_mutation",
"read_audit_mode": "best_effort_allowlisted",
"correlation_header": "X-Request-Id",
"rp_business_audit_required": true,
"baron_gateway_audit_required": true,
"required_detail_fields": []string{
"obj_id",
"relation",
"client_id",
"subject",
"decision",
},
"guarantee_scope": "Baron-mediated IAM mutations fail closed on audit write failure; RP-owned business events must be emitted by the RP with the same correlation header.",
},
"security_requirements": map[string]any{
"strip_external_identity_headers": true,
"backend_direct_exposure_allowed": false,
"static_snapshot_requires_auth": true,
"email_as_primary_key_allowed": false,
},
}
}
func rpManifestSchema() map[string]any {
return map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Baron RP IAM Manifest",
"type": "object",
"required": []string{
"version",
"issuer",
"oidc",
"iam",
"trusted_headers",
"identity_contract",
"object_lookup",
"object_lookup_flow",
"external_key_flow",
"audit_contract",
"security_requirements",
},
"properties": map[string]any{
"version": map[string]any{"type": "string"},
"issuer": map[string]any{"type": "string", "format": "uri"},
"oidc": map[string]any{"type": "object"},
"iam": map[string]any{"type": "object"},
"trusted_headers": map[string]any{"type": "object"},
"identity_contract": map[string]any{"type": "object"},
"object_lookup": map[string]any{"type": "object"},
"object_lookup_flow": map[string]any{"type": "object"},
"external_key_flow": map[string]any{"type": "object"},
"audit_contract": map[string]any{"type": "object"},
"security_requirements": map[string]any{"type": "object"},
},
}
}

View File

@@ -0,0 +1,123 @@
package handler
import (
"encoding/json"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
func TestRPManifestJSONIncludesIAMAndExternalKeyContract(t *testing.T) {
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest.json", h.GetJSON)
req := httptest.NewRequest("GET", "/.well-known/baron-rp-manifest.json", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "sso.hmac.kr")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, "https://sso.hmac.kr", body["issuer"])
oidc := body["oidc"].(map[string]any)
require.Equal(t, "https://sso.hmac.kr/.well-known/openid-configuration", oidc["discovery_url"])
require.Equal(t, "https://sso.hmac.kr/.well-known/jwks.json", oidc["jwks_url"])
iam := body["iam"].(map[string]any)
require.Equal(t, "ory-keto", iam["authorization_engine"])
require.Equal(t, "User:<baron_identity_id>", iam["subject_format"])
require.Contains(t, iam["target_object_patterns"].([]any), "RelyingParty:<client_id>")
require.Contains(t, iam["target_object_patterns"].([]any), "Tenant:<tenant_id>")
require.Contains(t, iam["target_object_patterns"].([]any), "Resource:<resource_type>:<resource_id>")
identity := body["identity_contract"].(map[string]any)
require.Equal(t, "X-Baron-External-Key", identity["external_key_header"])
require.Equal(t, true, identity["external_key_is_opaque"])
require.Equal(t, false, identity["raw_kratos_identity_id_exposed"])
require.Equal(t, "baron", identity["external_key_issuer"])
require.Equal(t, "baron_injected_header", identity["external_key_delivery"])
require.Equal(t, false, identity["rp_supplied_external_key_allowed"])
require.Equal(t, "rp_must_upsert_from_header_value", identity["rp_user_upsert_source"])
headers := body["trusted_headers"].(map[string]any)
require.Equal(t, "X-Baron-Subject", headers["subject"])
require.Equal(t, "X-Baron-External-Key", headers["external_key"])
require.Equal(t, "X-Baron-Client-ID", headers["client_id"])
security := body["security_requirements"].(map[string]any)
require.Equal(t, true, security["strip_external_identity_headers"])
require.Equal(t, false, security["backend_direct_exposure_allowed"])
audit := body["audit_contract"].(map[string]any)
require.Equal(t, "fail_closed_sync", audit["mutating_command_mode"])
require.Equal(t, "reject_mutation", audit["missing_audit_sink_behavior"])
require.Equal(t, "X-Request-Id", audit["correlation_header"])
require.Contains(t, audit["required_detail_fields"].([]any), "obj_id")
require.Contains(t, audit["required_detail_fields"].([]any), "client_id")
flow := body["object_lookup_flow"].(map[string]any)
require.Contains(t, flow["mermaid"].(string), "flowchart TD")
require.Contains(t, flow["mermaid"].(string), "obj_id")
aliasFlow := body["external_key_flow"].(map[string]any)
require.Contains(t, aliasFlow["mermaid"].(string), "Baron resolves internal identity")
require.Contains(t, aliasFlow["mermaid"].(string), "Baron injects X-Baron-External-Key")
require.Contains(t, aliasFlow["mermaid"].(string), "RP upserts local user")
require.NotContains(t, aliasFlow["mermaid"].(string), "RP creates external key")
}
func TestRPManifestSchemaRequiresLookupAndIdentityContracts(t *testing.T) {
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest.schema.json", h.GetSchema)
resp, err := app.Test(httptest.NewRequest("GET", "/.well-known/baron-rp-manifest.schema.json", nil))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, fiber.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
required := body["required"].([]any)
require.Contains(t, required, "iam")
require.Contains(t, required, "trusted_headers")
require.Contains(t, required, "identity_contract")
require.Contains(t, required, "object_lookup")
require.Contains(t, required, "audit_contract")
require.Contains(t, required, "object_lookup_flow")
require.Contains(t, required, "external_key_flow")
}
func TestRPManifestHTMLLinksMachineReadableManifest(t *testing.T) {
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest", h.GetHTML)
resp, err := app.Test(httptest.NewRequest("GET", "/.well-known/baron-rp-manifest", nil))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Type"), "text/html")
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
text := string(raw)
require.Contains(t, text, "/.well-known/baron-rp-manifest.json")
require.Contains(t, text, "X-Baron-External-Key")
require.Contains(t, text, "RelyingParty:&lt;client_id&gt;")
require.Contains(t, text, "```mermaid")
require.Contains(t, text, "audit_contract")
require.Contains(t, text, "Baron-issued alias")
require.Contains(t, text, "RP upserts local user")
}

View File

@@ -391,7 +391,12 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
}
}
tenant, err := h.createTenantCSVRecord(c, record, creatorID)
recordCreatorID := creatorID
if record.Type == domain.TenantTypeOrganization {
recordCreatorID = ""
}
tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
@@ -632,11 +637,142 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
normalized[key] = fields
continue
}
if key == "visibility" {
visibility, ok := value.(string)
if !ok {
return nil, fmt.Errorf("visibility must be public, internal, or private")
}
visibility = strings.TrimSpace(strings.ToLower(visibility))
if visibility == "" || visibility == "public" {
normalized[key] = "public"
continue
}
if visibility != "internal" && visibility != "private" {
return nil, fmt.Errorf("visibility must be public, internal, or private")
}
normalized[key] = visibility
continue
}
if key == "orgUnitType" {
orgUnitType, ok := value.(string)
if !ok {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부")
}
orgUnitType = strings.TrimSpace(orgUnitType)
if orgUnitType == "" {
continue
}
if !isAllowedOrgUnitType(orgUnitType) {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부")
}
normalized[key] = orgUnitType
continue
}
normalized[key] = value
}
return normalized, nil
}
func isAllowedOrgUnitType(value string) bool {
switch value {
case "실", "팀", "디비전", "셀", "본부", "지역본부", "부":
return true
default:
return false
}
}
func hasTenantOrgConfig(config domain.JSONMap) bool {
if config == nil {
return false
}
_, hasVisibility := config["visibility"]
_, hasOrgUnitType := config["orgUnitType"]
return hasVisibility || hasOrgUnitType
}
func isHanmacFamilyDescendantTenant(tenant domain.Tenant, tenants []domain.Tenant) bool {
if strings.EqualFold(tenant.Slug, "hanmac-family") {
return false
}
byID := make(map[string]domain.Tenant, len(tenants)+1)
for _, item := range tenants {
byID[item.ID] = item
}
byID[tenant.ID] = tenant
parentID := tenant.ParentID
visited := make(map[string]bool)
for parentID != nil && *parentID != "" {
if visited[*parentID] {
return false
}
visited[*parentID] = true
parent, ok := byID[*parentID]
if !ok {
return false
}
if strings.EqualFold(parent.Slug, "hanmac-family") {
return true
}
parentID = parent.ParentID
}
return false
}
func validateTenantOrgConfigScope(tenant domain.Tenant, tenants []domain.Tenant, config domain.JSONMap) error {
if !hasTenantOrgConfig(config) {
return nil
}
if isHanmacFamilyDescendantTenant(tenant, tenants) {
return nil
}
return fmt.Errorf("tenant org config is allowed only hanmac-family descendants")
}
func tenantVisibility(config domain.JSONMap) string {
visibility, _ := config["visibility"].(string)
switch strings.ToLower(strings.TrimSpace(visibility)) {
case "internal":
return "internal"
case "private":
return "private"
default:
return "public"
}
}
func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant {
excludedIDs := make(map[string]bool)
for _, tenant := range tenants {
visibility := tenantVisibility(tenant.Config)
if visibility == "internal" || visibility == "private" {
excludedIDs[tenant.ID] = true
}
}
changed := true
for changed {
changed = false
for _, tenant := range tenants {
if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] {
excludedIDs[tenant.ID] = true
changed = true
}
}
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if !excludedIDs[tenant.ID] {
filtered = append(filtered, tenant)
}
}
return filtered
}
func normalizeTenantUserSchema(value any) ([]any, error) {
if value == nil {
return nil, nil
@@ -1023,6 +1159,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var tenants []domain.Tenant
if hasTenantOrgConfig(config) {
if err := h.DB.Find(&tenants).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := validateTenantOrgConfigScope(*tenant, tenants, config); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
tenant.Config = config
h.DB.Save(tenant)
summary.Config = tenant.Config
@@ -1162,6 +1307,15 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var tenants []domain.Tenant
if hasTenantOrgConfig(config) {
if err := h.DB.Find(&tenants).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := validateTenantOrgConfigScope(tenant, tenants, config); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
tenant.Config = config
}
@@ -1696,10 +1850,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
for _, t := range allTenants {
if findRoot(t.ID) == sharedRootID {
filteredTenants = append(filteredTenants, t)
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
}
filteredTenants = filterPublicTenants(filteredTenants)
for _, t := range filteredTenants {
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
type publicUserSummary struct {
ID string `json:"id"`

View File

@@ -610,6 +610,52 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id"})
return c.Next()
})
app.Post("/tenants/import", h.ImportTenantsCSV)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Org,ORGANIZATION,parent-1,imported-org,,\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On(
"RegisterTenant",
mock.Anything,
"Imported Org",
"imported-org",
domain.TenantTypeOrganization,
"",
[]string{},
mock.MatchedBy(func(parentID *string) bool {
return parentID != nil && *parentID == "parent-1"
}),
"",
).Return(&domain.Tenant{ID: "imported-org-id", Name: "Imported Org", Slug: "imported-org"}, nil).Once()
req := httptest.NewRequest("POST", "/tenants/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{}
json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(1), got["created"])
assert.Equal(t, float64(0), got["failed"])
mockSvc.AssertExpectations(t)
}
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}
@@ -681,6 +727,62 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
assert.Contains(t, err.Error(), "login ID fields must be text")
}
func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{
"visibility": "internal",
"orgUnitType": "팀",
})
assert.NoError(t, err)
assert.Equal(t, "internal", config["visibility"])
assert.Equal(t, "팀", config["orgUnitType"])
}
func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) {
_, err := normalizeTenantConfig(map[string]any{
"visibility": "secret",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "visibility must be public, internal, or private")
}
func TestValidateTenantOrgConfigScopeRequiresHanmacFamilyDescendant(t *testing.T) {
hanmacFamily := domain.Tenant{ID: "family", Slug: "hanmac-family", Type: domain.TenantTypeCompanyGroup}
saman := domain.Tenant{ID: "saman", Slug: "saman", Type: domain.TenantTypeCompany, ParentID: &hanmacFamily.ID}
outsider := domain.Tenant{ID: "outsider", Slug: "outsider", Type: domain.TenantTypeCompany}
err := validateTenantOrgConfigScope(saman, []domain.Tenant{hanmacFamily, saman}, domain.JSONMap{
"visibility": "private",
"orgUnitType": "팀",
})
assert.NoError(t, err)
err = validateTenantOrgConfigScope(outsider, []domain.Tenant{hanmacFamily, saman, outsider}, domain.JSONMap{
"visibility": "private",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "only hanmac-family descendants")
}
func TestFilterPublicTenantsExcludesInternalPrivateAndDescendants(t *testing.T) {
root := domain.Tenant{ID: "root", Slug: "hanmac-family"}
publicTenant := domain.Tenant{ID: "public", Slug: "public", ParentID: &root.ID}
internalTenant := domain.Tenant{ID: "internal", Slug: "internal", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "internal"}}
privateTenant := domain.Tenant{ID: "private", Slug: "private", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "private"}}
privateChild := domain.Tenant{ID: "private-child", Slug: "private-child", ParentID: &privateTenant.ID}
filtered := filterPublicTenants([]domain.Tenant{
root,
publicTenant,
internalTenant,
privateTenant,
privateChild,
})
assert.Equal(t, []domain.Tenant{root, publicTenant}, filtered)
}
func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -144,6 +144,27 @@ func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
return false, false
}
func roleFromTraits(traits map[string]interface{}) string {
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
return role
}
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "grade")); ok {
return role
}
return domain.RoleUser
}
func gradeFromTraits(traits map[string]interface{}) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
return ""
}
if _, ok := domain.NormalizeRoleAlias(value); ok {
return ""
}
return value
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -158,6 +179,7 @@ type userSummary struct {
Tenant *domain.Tenant `json:"tenant,omitempty"`
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
CreatedAt string `json:"createdAt"`
@@ -429,6 +451,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
PrimaryTenantID string `json:"primaryTenantId"`
@@ -488,11 +511,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": req.Department,
"grade": strings.TrimSpace(req.Grade),
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
"companyCode": req.CompanyCode,
"grade": role,
}
// [Override with explicit LoginID if provided]
@@ -648,6 +671,7 @@ type bulkUserItem struct {
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
@@ -820,12 +844,12 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": dept,
"grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
"grade": role,
"role": role,
}
@@ -889,6 +913,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Status: "active",
CompanyCode: tenantSlug,
Department: dept,
Grade: strings.TrimSpace(item.Grade),
AffiliationType: "internal",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -1059,9 +1084,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
// Header row
includeIDs := includeCSVIds(c)
header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Position", "JobTitle", "CreatedAt"}
header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
if includeIDs {
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Position", "JobTitle", "CreatedAt"}
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
}
// Collect all possible metadata keys for dynamic columns
@@ -1096,6 +1121,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.Phone,
u.Status,
u.CompanyCode,
u.Grade,
u.Position,
u.JobTitle,
u.CreatedAt.Format(time.RFC3339),
@@ -1109,6 +1135,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.Status,
tenantID,
u.CompanyCode,
u.Grade,
u.Position,
u.JobTitle,
u.CreatedAt.Format(time.RFC3339),
@@ -1142,6 +1169,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
Role *string `json:"role"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Grade *string `json:"grade"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
}
@@ -1233,6 +1261,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if req.Department != nil {
traits["department"] = *req.Department
}
if req.Grade != nil {
traits["grade"] = *req.Grade
}
if req.Position != nil {
traits["position"] = *req.Position
}
@@ -1258,7 +1289,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Sync to local DB
if h.UserRepo != nil {
localUser := h.mapToLocalUser(*identity)
oldRole := extractTraitString(identity.Traits, "grade")
oldRole := roleFromTraits(identity.Traits)
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
if req.Role != nil {
@@ -1437,6 +1468,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
Department *string `json:"department"`
Grade *string `json:"grade"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
PrimaryTenantID string `json:"primaryTenantId"`
@@ -1658,6 +1690,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
}
if req.Grade != nil {
traits["grade"] = strings.TrimSpace(*req.Grade)
}
if req.Position != nil {
traits["position"] = strings.TrimSpace(*req.Position)
}
@@ -1669,7 +1704,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if role == "" {
role = domain.RoleUser
}
traits["grade"] = role
traits["role"] = role
}
@@ -1757,7 +1791,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
go func() {
bgCtx := context.Background()
h.syncKetoRole(bgCtx, updatedLocalUser.ID,
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
roleFromTraits(updated.Traits), oldRole, oldTenantID, updatedLocalUser.TenantID)
// Try to automatically sync UserGroup membership based on Department
if h.UserGroupRepo != nil && h.KetoOutboxRepo != nil {
@@ -1911,14 +1945,7 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
traits := identity.Traits
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
@@ -1947,6 +1974,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
Metadata: make(domain.JSONMap),
@@ -1997,14 +2025,7 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
traits := identity.Traits
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
@@ -2019,6 +2040,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
AffiliationType: extractTraitString(traits, "affiliationType"),

View File

@@ -190,7 +190,8 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
Status: "active",
CompanyCode: "test-tenant",
Department: "Legacy Department",
Position: "책임",
Grade: "책임",
Position: "팀장",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
@@ -203,8 +204,8 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,,test-tenant")
assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Grade,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,,test-tenant,책임,팀장")
assert.NotContains(t, body, "Role")
assert.NotContains(t, body, "Department")
mockRepo.AssertExpectations(t)
@@ -235,7 +236,8 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
Status: "active",
CompanyCode: "test-tenant",
TenantID: &tenantID,
Position: "책임",
Grade: "책임",
Position: "팀장",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
@@ -248,8 +250,8 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant")
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Grade,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant,책임,팀장")
assert.NotContains(t, body, "user-uuid")
assert.NotContains(t, body, "tenant-uuid")
assert.NotContains(t, body, "ID,")
@@ -1185,6 +1187,29 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
mockOry.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{
ID: "user-grade-id",
State: "active",
Traits: map[string]interface{}{
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"companyCode": "hanmac",
},
}
localUser := handler.mapToLocalUser(identity)
assert.Equal(t, domain.RoleUser, localUser.Role)
assert.Equal(t, "수석", localUser.Grade)
assert.Equal(t, "팀장", localUser.Position)
assert.NotContains(t, localUser.Metadata, "grade")
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
return "", nil
}

View File

@@ -301,13 +301,16 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
updatedAt = now
}
role := userGroupTraitString(traits, "grade")
if role == "" {
role = userGroupTraitString(traits, "role")
role, ok := domain.NormalizeRoleAlias(userGroupTraitString(traits, "role"))
if !ok {
role, ok = domain.NormalizeRoleAlias(userGroupTraitString(traits, "grade"))
if !ok {
role = domain.RoleUser
}
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
grade := userGroupTraitString(traits, "grade")
if _, ok := domain.NormalizeRoleAlias(grade); ok {
grade = ""
}
companyCode := userGroupTraitString(traits, "companyCode")
@@ -324,6 +327,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
Status: userGroupIdentityStatus(identity.State),
CompanyCode: companyCode,
Department: userGroupTraitString(traits, "department"),
Grade: grade,
Position: userGroupTraitString(traits, "position"),
JobTitle: userGroupTraitString(traits, "jobTitle"),
AffiliationType: userGroupTraitString(traits, "affiliationType"),

View File

@@ -61,13 +61,16 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
updatedAt = now
}
role := kratosProjectionTraitString(traits, "grade")
if role == "" {
role = kratosProjectionTraitString(traits, "role")
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
if !ok {
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
if !ok {
role = domain.RoleUser
}
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
grade := kratosProjectionTraitString(traits, "grade")
if _, ok := domain.NormalizeRoleAlias(grade); ok {
grade = ""
}
companyCode := kratosProjectionTraitString(traits, "companyCode")
@@ -85,6 +88,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
CompanyCode: companyCode,
CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")),
Department: kratosProjectionTraitString(traits, "department"),
Grade: grade,
Position: kratosProjectionTraitString(traits, "position"),
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),

View File

@@ -0,0 +1,82 @@
# 외부 RP Ory IAM 연동 가이드 초안
본 문서는 외부 RP가 자체 IAM을 만들지 않고 Baron SSO/Ory Stack/Keto 기반 공용 IAM을 연동하기 위한 초안입니다.
## 공개 Manifest
- HTML: `/.well-known/baron-rp-manifest`
- JSON: `/.well-known/baron-rp-manifest.json`
- JSON Schema: `/.well-known/baron-rp-manifest.schema.json`
RP는 JSON manifest를 우선 기준으로 삼고, HTML 페이지는 사람이 확인하는 규격 문서로 사용합니다.
## Identity Contract
RP는 raw `kratos_identity_id`를 비즈니스 키로 저장하거나 파싱하지 않습니다. `X-Baron-External-Key`는 RP가 생성하거나 제출하는 값이 아니라, Baron이 인증된 subject를 기준으로 발급 또는 조회해서 RP 요청 직전에 주입하는 Baron-issued alias입니다.
- `X-Baron-Subject`: Keto 권한 판정 subject입니다. 예: `User:<baron_identity_id>`
- `X-Baron-External-Key`: RP의 local user insert/upsert에 쓰는 opaque external key입니다. RP는 이 값을 해석하지 않고 전체 문자열 그대로 저장합니다.
- `X-Baron-Client-ID`: 현재 요청이 속한 RP client id입니다.
RP의 local user key는 `provider + external_key` 조합으로 저장합니다. 이메일은 변경될 수 있으므로 stable primary key로 사용하지 않습니다.
정리하면 “RP가 알고 저장할 수 있는 값”은 Baron이 주입한 canonical external alias뿐입니다. RP가 alias를 직접 만들거나 raw `kratos_identity_id`에서 alias를 계산하면 안 됩니다. 최초 로그인 또는 최초 접근 시 RP가 사용자를 생성해야 한다면, Baron이 이미 주입한 `X-Baron-External-Key`를 사용해 insert/upsert합니다.
```mermaid
flowchart TD
A[User authenticates through Baron SSO] --> B[Baron resolves internal identity]
B --> C[Baron derives or loads Baron-issued alias]
C --> D[Baron injects X-Baron-External-Key]
D --> E[Baron injects X-Baron-Subject]
E --> I[RP receives trusted headers from Baron gateway]
I --> F[RP upserts local user with provider + X-Baron-External-Key]
F --> G[RP stores the full external key as opaque value]
G --> H[RP never parses or stores raw kratos_identity_id]
```
## obj_id 조회 흐름
`obj_id`는 Keto check의 target object입니다. 명시적으로 전달된 `obj_id`가 있으면 정규화 후 사용하고, 없으면 route context에서 `client_id`, `tenant_id` 순서로 추론합니다. 둘 다 없으면 RP가 명확한 target object를 제공하지 않은 것이므로 요청을 거부해야 합니다.
```mermaid
flowchart TD
A[RP request] --> B{obj_id supplied?}
B -->|yes| C[Normalize object type and obj_id]
B -->|no| D{Route has client_id?}
D -->|yes| E[obj_id = RelyingParty:<client_id>]
D -->|no| F{Route has tenant_id?}
F -->|yes| G[obj_id = Tenant:<tenant_id>]
F -->|no| H[Reject: explicit obj_id required]
C --> I[Check Keto relation]
E --> I
G --> I
I --> J{allowed?}
J -->|yes| K[Inject trusted Baron headers]
J -->|no| L[Reject request]
K --> M[Write audit with obj_id, relation, client_id, X-Request-Id]
```
대표 object 패턴은 다음과 같습니다.
- RP 단위: `RelyingParty:<client_id>`
- Tenant 단위: `Tenant:<tenant_id>`
- RP 내부 리소스 단위: `Resource:<resource_type>:<resource_id>`
## Audit Contract
audit 누락 방지는 범위를 나눠서 보장합니다.
- Baron이 중개하는 IAM mutation은 `fail_closed_sync`입니다. audit write가 실패하면 원 요청도 실패해야 합니다.
- audit sink가 없거나 사용할 수 없으면 mutation은 `reject_mutation`으로 처리합니다.
- allowlist된 read audit은 부하 보호를 위해 best effort로 둘 수 있으나, 권한/설정 변경 command에는 적용하지 않습니다.
- RP 자체 비즈니스 이벤트는 RP가 동일한 `X-Request-Id`를 correlation key로 사용해 audit을 남겨야 합니다.
필수 audit detail 필드는 다음과 같습니다.
- `obj_id`
- `relation`
- `client_id`
- `subject`
- `decision`
따라서 “audit 누락 없음”은 Baron-mediated IAM command에 대해 보장합니다. RP 내부에서 직접 발생하는 비즈니스 이벤트까지 포함하려면 RP가 이 audit contract를 구현하고, audit 저장 실패 시 동일하게 fail closed 처리해야 합니다.

View File

@@ -34,7 +34,7 @@ Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organizat
| 용도 | API | 주요 사용 필드 |
| --- | --- | --- |
| 테넌트 목록 | `GET /v1/admin/tenants?limit=10000&offset=0` | `id`, `type`, `name`, `slug`, `parentId`, `memberCount`, `status` |
| 사용자 목록 | `GET /v1/admin/users?limit=5000&offset=0` | `id`, `email`, `name`, `status`, `tenantSlug`, `companyCode`, `joinedTenants`, `position`, `jobTitle` |
| 사용자 목록 | `GET /v1/admin/users?limit=5000&offset=0` | `id`, `email`, `name`, `status`, `tenantSlug`, `companyCode`, `joinedTenants`, `grade`, `position`, `jobTitle` |
테넌트는 Baron Admin에서 입력한 `parentId` 관계를 기준으로 트리로 변환합니다. 현재 루트 후보는 `type === "COMPANY_GROUP"`인 테넌트를 우선 사용하고, 없으면 최상위 테넌트를 사용합니다. 회사 필터는 루트 하위의 `type === "COMPANY"` 테넌트로 구성됩니다.
@@ -47,7 +47,7 @@ Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organizat
5. `joinedTenants`가 있으면 각 joined tenant의 `slug`에도 같은 사용자를 추가합니다.
6. 같은 테넌트 노드 안에서 동일 사용자 `id`는 중복 추가하지 않습니다.
각 조직도 노드는 테넌트명(`name`)을 헤더로 사용하고, 해당 테넌트 `slug`에 매핑된 사용자를 구성원 목록으로 표시합니다. 구성원은 `position`/`jobTitle` 기준으로 정렬되며, 표시 직무는 `jobTitle || position || "사원"` 순서로 결정됩니다.
각 조직도 노드는 테넌트명(`name`)을 헤더로 사용하고, 해당 테넌트 `slug`에 매핑된 사용자를 구성원 목록으로 표시합니다. 구성원은 직책(`position`) 조직장 여부와 직급(`grade`) 기준으로 정렬되며, 사용자 표시는 `이름 직급(직책)` 형식을 우선 사용하고 직책이 없으면 직무(`jobTitle`)를 보조 표시로 사용합니다.
### 공유 조직도 화면

View File

@@ -14,6 +14,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"@xyflow/react": "^12.10.2",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -433,38 +434,35 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -527,9 +525,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -584,9 +582,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1058,9 +1056,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"cpu": [
"arm64"
],
@@ -1075,9 +1073,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"cpu": [
"arm64"
],
@@ -1092,9 +1090,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"cpu": [
"x64"
],
@@ -1109,9 +1107,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"cpu": [
"x64"
],
@@ -1126,9 +1124,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"cpu": [
"arm"
],
@@ -1143,13 +1141,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1160,13 +1161,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1177,13 +1181,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1194,13 +1201,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1211,13 +1221,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1228,13 +1241,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1245,9 +1261,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"cpu": [
"arm64"
],
@@ -1262,9 +1278,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"cpu": [
"wasm32"
],
@@ -1272,16 +1288,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"cpu": [
"arm64"
],
@@ -1296,9 +1314,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"cpu": [
"x64"
],
@@ -1380,9 +1398,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1401,6 +1419,55 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1584,6 +1651,38 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1676,14 +1775,14 @@
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -1873,6 +1972,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1961,6 +2066,111 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -2203,9 +2413,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -3051,9 +3261,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3116,9 +3326,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -3279,10 +3489,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -3463,14 +3676,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
"@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3479,27 +3692,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
"@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.0"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true,
"license": "MIT"
},
@@ -3719,14 +3932,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -3754,9 +3967,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3929,17 +4142,17 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.14",
"rolldown": "1.0.0",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -3955,8 +4168,8 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
@@ -4227,6 +4440,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"@xyflow/react": "^12.10.2",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { buildOrgPickerTree } from "./pickerTree";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("buildOrgPickerTree", () => {
it("uses the hanmac-family company-group as the default picker root", () => {
const tenants = [
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
expect(tree.companyGroupId).toBe("hanmac-family-id");
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"saman-id",
]);
});
it("scopes descendant filtering by tenant slug", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("saman-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"planning-id",
]);
});
it("excludes private tenants and their descendants from picker choices", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"secret-id",
"ORGANIZATION",
"비공개 조직",
"secret",
"saman-id",
),
config: { visibility: "private" },
},
tenant(
"secret-child-id",
"USER_GROUP",
"비공개 하위",
"secret-child",
"secret-id",
),
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
});
});

View File

@@ -1,6 +1,7 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
import type { OrgPickerTreeNode } from "./pickerTypes";
import { filterTenantsByVisibility } from "./tenantVisibility";
import { getOrgChartUserDisplayName } from "./userDisplay";
function getUserTenantSlug(user: UserSummary) {
@@ -28,6 +29,23 @@ function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
}
function isHanmacFamilyCompanyGroup(tenant: TenantSummary) {
return (
tenant.type.toUpperCase() === "COMPANY_GROUP" &&
tenant.slug.toLowerCase() === "hanmac-family"
);
}
function findTenantByRef(tenants: TenantSummary[], ref?: string) {
const normalizedRef = ref?.trim().toLowerCase();
if (!normalizedRef) return undefined;
return (
tenants.find((tenant) => tenant.slug.toLowerCase() === normalizedRef) ??
tenants.find((tenant) => tenant.id === ref)
);
}
function tenantToPickerNode(
tenant: TenantNode,
usersBySlug: Map<string, UserSummary[]>,
@@ -58,12 +76,34 @@ function tenantToPickerNode(
function findTenantNode(
roots: TenantNode[],
tenantId: string,
tenantRef: string,
): TenantNode | undefined {
const findBySlug = (node: TenantNode): TenantNode | undefined => {
if (node.slug.toLowerCase() === tenantRef.trim().toLowerCase()) {
return node;
}
for (const child of node.children) {
const match = findBySlug(child);
if (match) return match;
}
return undefined;
};
const findById = (node: TenantNode): TenantNode | undefined => {
if (node.id === tenantRef) return node;
for (const child of node.children) {
const match = findById(child);
if (match) return match;
}
return undefined;
};
for (const root of roots) {
if (root.id === tenantId) return root;
const child = findTenantNode(root.children, tenantId);
if (child) return child;
const slugMatch = findBySlug(root);
if (slugMatch) return slugMatch;
}
for (const root of roots) {
const idMatch = findById(root);
if (idMatch) return idMatch;
}
return undefined;
}
@@ -79,7 +119,10 @@ export function buildOrgPickerTree({
rootTenantId?: string;
tenantId?: string;
}) {
const visibleTenants = tenants.filter(isOrgFrontTenantType);
const visibleTenants = filterTenantsByVisibility(
tenants.filter(isOrgFrontTenantType),
"internal",
);
const usersBySlug = new Map<string, UserSummary[]>();
for (const user of users) {
if (user.status !== "active") continue;
@@ -91,7 +134,8 @@ export function buildOrgPickerTree({
}
const companyGroup =
visibleTenants.find((tenant) => tenant.id === rootTenantId) ??
findTenantByRef(visibleTenants, rootTenantId) ??
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
visibleTenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
visibleTenants.find((tenant) => !tenant.parentId);

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildOrgPickerEmbedSrc,
parseOrgPickerEmbedOptions,
} from "./pickerTypes";
describe("org picker embed options", () => {
it("builds slug-based tenant scope urls", () => {
expect(
buildOrgPickerEmbedSrc({
mode: "single",
select: "tenant",
includeDescendants: true,
showDescendantToggle: true,
tenantId: "saman",
width: 400,
height: 600,
}),
).toBe(
"/embed/picker?mode=single&select=tenant&width=400&height=600&tenantSlug=saman",
);
});
it("parses tenantSlug first and keeps legacy tenantId compatibility", () => {
expect(
parseOrgPickerEmbedOptions(
"?tenantId=legacy-id&tenantSlug=saman&companyTenantId=legacy-company",
).tenantId,
).toBe("saman");
expect(parseOrgPickerEmbedOptions("?tenantId=legacy-id").tenantId).toBe(
"legacy-id",
);
});
});

View File

@@ -70,7 +70,11 @@ export function parseOrgPickerEmbedOptions(search: string) {
select: parseOrgPickerSelectableType(params.get("select")),
includeDescendants: params.get("includeDescendants") !== "false",
showDescendantToggle: params.get("showDescendantToggle") !== "false",
tenantId: params.get("tenantId") ?? params.get("companyTenantId") ?? "",
tenantId:
params.get("tenantSlug") ??
params.get("tenantId") ??
params.get("companyTenantId") ??
"",
width: parseEmbedDimension(params.get("width"), 400),
height: parseEmbedDimension(params.get("height"), 600),
};
@@ -84,9 +88,9 @@ export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
height: String(options.height),
});
const tenantId = options.tenantId.trim();
if (tenantId) {
params.set("tenantId", tenantId);
const tenantSlug = options.tenantId.trim();
if (tenantSlug) {
params.set("tenantSlug", tenantSlug);
}
if (options.mode === "multiple") {

View File

@@ -0,0 +1,388 @@
import { describe, expect, it } from "vitest";
import {
type OrgNode,
buildOrgSelectionOptions,
clampScale,
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
} from "./OrgChartPage";
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
return {
id,
name: id,
level,
members: [],
children,
totalCount: 0,
totalMemberIds: new Set<string>(),
companyCode: id,
type: level === 0 ? "COMPANY" : "USER_GROUP",
};
}
function member(id: string) {
return {
id,
email: `${id}@example.com`,
name: id,
role: "user",
status: "active",
companyCode: "root",
grade: "사원",
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
function tenantNode(
id: string,
type: string,
name: string,
slug: string,
children = [],
) {
return {
id,
type,
name,
slug,
children,
description: "",
status: "active",
memberCount: 0,
recursiveMemberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
function getNodeBoundsAspectRatio(
nodes: ReturnType<typeof layoutForest>["nodes"],
) {
const minX = Math.min(...nodes.map((node) => node.x));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const minY = Math.min(...nodes.map((node) => node.y));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
return (maxX - minX) / (maxY - minY);
}
describe("org chart layout", () => {
it("keeps small sibling groups horizontal in automatic mode", () => {
const children = Array.from({ length: 4 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
expect(new Set(childNodes.map((node) => node.y)).size).toBe(1);
});
it("uses member columns in node bounds when member count exceeds five", () => {
const compactMembers = Array.from({ length: 6 }, (_, index) =>
member(`member-${index + 1}`),
);
const node = {
...orgNode("root"),
members: compactMembers,
totalCount: compactMembers.length,
totalMemberIds: new Set(compactMembers.map((item) => item.id)),
};
const layout = layoutForest([node], new Set());
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode).toBeDefined();
expect(rootNode?.width).toBeGreaterThan(340);
expect(rootNode?.height).toBeLessThan(42 + 24 + 6 * 24);
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
});
it("adds one member column per five-member quotient", () => {
const tenMembers = Array.from({ length: 10 }, (_, index) =>
member(`member-${index + 1}`),
);
const sixMembers = tenMembers.slice(0, 6);
const sixLayout = layoutForest(
[
{
...orgNode("six"),
members: sixMembers,
totalCount: sixMembers.length,
totalMemberIds: new Set(sixMembers.map((item) => item.id)),
},
],
new Set(),
);
const tenLayout = layoutForest(
[
{
...orgNode("ten"),
members: tenMembers,
totalCount: tenMembers.length,
totalMemberIds: new Set(tenMembers.map((item) => item.id)),
},
],
new Set(),
);
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
expect(sixNode?.width).toBeGreaterThan(340);
expect(tenNode?.width).toBeGreaterThan(sixNode?.width ?? 0);
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
expect(tenLayout.width).toBeGreaterThan(sixLayout.width);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
const childSpan =
Math.max(...childNodes.map((node) => node.x + node.width)) -
Math.min(...childNodes.map((node) => node.x));
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
expect(childNodes).toHaveLength(13);
expect(uniqueChildRows.size).toBeGreaterThan(1);
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
expect(aspectRatio).toBeLessThanOrEqual(1.61);
expect(childSpan).toBeLessThan(13 * 340 + 12 * 80);
expect(
layout.edges.filter((edge) => edge.key.startsWith("root->")),
).toHaveLength(13);
expect(
layout.edges.filter(
(edge) => edge.key.startsWith("root->") && edge.visibleByDefault,
),
).toHaveLength(new Set(childNodes.map((node) => node.x)).size);
});
it("tunes column and row gaps after column selection to keep auto layout near the target aspect ratio", () => {
const children = Array.from({ length: 5 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
expect(new Set(childNodes.map((node) => node.x)).size).toBe(2);
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
expect(aspectRatio).toBeLessThanOrEqual(1.61);
});
it("keeps direct siblings on one level in top-down mode", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "topDown",
});
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
expect(childNodes).toHaveLength(13);
expect(uniqueChildRows.size).toBe(1);
});
it("places children in three fixed columns with centered parent edges", () => {
const children = Array.from({ length: 10 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildColumns = new Set(childNodes.map((node) => node.x));
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
expect(uniqueChildColumns.size).toBe(3);
expect(uniqueChildRows.size).toBe(4);
expect(rootEdges).toHaveLength(10);
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
});
it("places the deepest child subtree in the first multi-column section", () => {
const children = [
orgNode("shallow-1", [], 1),
orgNode("shallow-2", [], 1),
orgNode("shallow-3", [], 1),
orgNode(
"deep",
[
orgNode(
"deep-branch",
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
2,
),
],
1,
),
orgNode("shallow-4", [], 1),
orgNode("shallow-5", [], 1),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
});
it("centers a parent over the full child span in multi-column mode", () => {
const children = [
orgNode(
"deep",
[
orgNode(
"deep-branch",
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
2,
),
],
1,
),
...Array.from({ length: 9 }, (_, index) =>
orgNode(`shallow-${index + 1}`, [], 1),
),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootNode = layout.nodes.find((node) => node.node.id === "root");
const directChildren = layout.nodes.filter((node) => node.node.level === 1);
const childSpanCenter =
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
2;
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
expect(rootNode).toBeDefined();
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
});
it("centers parents above the tidy child span", () => {
const children = [
orgNode("left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1),
orgNode("middle", [], 1),
orgNode(
"right",
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
1,
),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "topDown",
});
const rootNode = layout.nodes.find((node) => node.node.id === "root");
const directChildren = layout.nodes.filter((node) =>
["left", "middle", "right"].includes(node.node.id),
);
const childSpanCenter =
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
2;
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
expect(rootNode).toBeDefined();
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
});
it("keeps compressed subtrees from overlapping on shared vertical bands", () => {
const layout = layoutForest(
[
orgNode("root", [
orgNode(
"left",
[orgNode("left-a", [], 2), orgNode("left-b", [], 2)],
1,
),
orgNode(
"right",
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
1,
),
]),
],
new Set(),
);
for (const node of layout.nodes) {
for (const other of layout.nodes) {
if (node.node.id >= other.node.id) continue;
const verticalOverlap =
node.y < other.y + other.height && other.y < node.y + node.height;
const horizontalOverlap =
node.x < other.x + other.width && other.x < node.x + node.width;
expect(
verticalOverlap && horizontalOverlap,
`${node.node.id} overlaps ${other.node.id}`,
).toBe(false);
}
}
});
it("keeps zoom limits wide enough for large SVG organization charts", () => {
expect(clampScale(0.08)).toBe(0.08);
expect(clampScale(5)).toBe(5);
});
it("switches semantic zoom modes from overview to detail", () => {
expect(getSemanticZoomMode(0.12)).toBe("overview");
expect(getSemanticZoomMode(0.4)).toBe("compact");
expect(getSemanticZoomMode(0.8)).toBe("detail");
});
it("uses distinct header fills by organization depth", () => {
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
expect(getOrgNodeHeaderFill(0, "hanmac")).toBe("#1e489d");
expect(getOrgNodeHeaderFill(0, "gpdtdc")).toBe("#4b746d");
expect(getOrgNodeHeaderFill(0, "baron")).toBe("#004cbf");
expect(getOrgNodeHeaderFill(1, "saman")).not.toBe(
getOrgNodeHeaderFill(0, "saman"),
);
expect(getOrgNodeHeaderFill(2, "saman")).not.toBe(
getOrgNodeHeaderFill(1, "saman"),
);
});
it("orders top organization choices by the hanmac family policy", () => {
const familyRoot = tenantNode(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
[
tenantNode("saman", "COMPANY", "삼안", "saman"),
tenantNode("baron", "COMPANY_GROUP", "바론그룹", "baron-group"),
tenantNode("hanmac", "COMPANY", "한맥기술", "hanmac"),
tenantNode("gpdtdc", "ORGANIZATION", "총괄기획&기술개발센터", "gpdtdc"),
],
);
expect(
buildOrgSelectionOptions(familyRoot).map((option) => option.label),
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { GitBranch, Network, PanelTop } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom";
import { NavLink, Outlet, useLocation } from "react-router-dom";
const navItems = [
{ to: "/chart", label: "조직도", icon: Network },
@@ -8,9 +8,22 @@ const navItems = [
];
export function OrgFrontLayout() {
const location = useLocation();
const isChartRoute =
location.pathname === "/chart" || location.pathname.startsWith("/chart/");
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div
className={
isChartRoute
? "flex h-screen flex-col overflow-hidden bg-background text-foreground"
: "min-h-screen bg-background text-foreground"
}
>
<header
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
data-testid="orgfront-topbar"
>
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
@@ -40,7 +53,14 @@ export function OrgFrontLayout() {
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-5">
<main
className={
isChartRoute
? "min-h-0 flex-1 overflow-hidden"
: "mx-auto max-w-7xl px-4 py-5"
}
data-testid="orgfront-main"
>
<Outlet />
</main>
</div>

View File

@@ -59,7 +59,7 @@ function PickerScenarioControls({
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant ID</span>
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
@@ -68,7 +68,7 @@ function PickerScenarioControls({
tenantId: event.target.value,
})
}
placeholder="company-baron"
placeholder="saman"
type="text"
value={options.tenantId}
/>

View File

@@ -334,6 +334,7 @@ export function OrgPickerEmbedPage() {
const select = parseOrgPickerSelectableType(searchParams.get("select"));
const rootTenantId = searchParams.get("rootTenantId") || undefined;
const tenantId =
searchParams.get("tenantSlug") ||
searchParams.get("tenantId") ||
searchParams.get("companyTenantId") ||
undefined;
@@ -615,7 +616,7 @@ export function OrgPickerPage() {
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant ID</span>
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
@@ -624,7 +625,7 @@ export function OrgPickerPage() {
tenantId: event.target.value,
}))
}
placeholder="company-baron"
placeholder="saman"
type="text"
value={options.tenantId}
/>

View File

@@ -0,0 +1,45 @@
import type { TenantSummary } from "../../lib/adminApi";
export function getTenantVisibility(tenant: Pick<TenantSummary, "config">) {
const raw = String(tenant.config?.visibility ?? "public").toLowerCase();
if (raw === "internal" || raw === "private") return raw;
return "public";
}
export function filterTenantsByVisibility(
tenants: TenantSummary[],
mode: "internal" | "public",
) {
const excludedIds = new Set<string>();
for (const tenant of tenants) {
const visibility = getTenantVisibility(tenant);
if (
visibility === "private" ||
(mode === "public" && visibility === "internal")
) {
excludedIds.add(tenant.id);
}
}
let changed = true;
while (changed) {
changed = false;
for (const tenant of tenants) {
if (
tenant.parentId &&
excludedIds.has(tenant.parentId) &&
!excludedIds.has(tenant.id)
) {
excludedIds.add(tenant.id);
changed = true;
}
}
}
return tenants.filter((tenant) => !excludedIds.has(tenant.id));
}
export function getOrgUnitType(config: Record<string, unknown> | undefined) {
const value = config?.orgUnitType;
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { UserSummary } from "../../lib/adminApi";
import { getOrgChartUserDisplayName } from "./userDisplay";
function user(overrides: Partial<UserSummary>): UserSummary {
return {
id: "user-1",
email: "user@example.com",
name: "홍길동",
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
...overrides,
};
}
describe("getOrgChartUserDisplayName", () => {
it("renders name with grade and optional position", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "수석",
position: "팀장",
}),
),
).toBe("홍길동 수석(팀장)");
});
it("uses tenant appointment grade before the user grade", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
grade: "수석",
position: "센터장",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
),
).toBe("홍길동 수석(센터장)");
});
});

View File

@@ -3,6 +3,7 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi";
type UserAppointment = {
tenantId?: string;
tenantSlug?: string;
grade?: string;
jobTitle?: string;
position?: string;
};
@@ -25,6 +26,7 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
.map((item) => ({
tenantId: normalizeText(item.tenantId),
tenantSlug: normalizeText(item.tenantSlug),
grade: normalizeText(item.grade),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
@@ -44,6 +46,7 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
});
return {
grade: appointment?.grade || normalizeText(user.grade),
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
};
@@ -53,11 +56,12 @@ export function getOrgChartUserDisplayName(
user: UserSummary,
tenant?: TenantIdentity,
) {
const { jobTitle, position } = getUserOrgProfile(user, tenant);
const { grade, jobTitle, position } = getUserOrgProfile(user, tenant);
const baseName = user.name.trim();
const detail = position || jobTitle;
if (jobTitle && position) return `${baseName}(${jobTitle}) ${position}`;
if (jobTitle) return `${baseName}(${jobTitle})`;
if (position) return `${baseName} ${position}`;
if (grade && detail) return `${baseName} ${grade}(${detail})`;
if (grade) return `${baseName} ${grade}`;
if (detail) return `${baseName}(${detail})`;
return baseName;
}

View File

@@ -388,6 +388,7 @@ export type UserSummary = {
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
@@ -410,6 +411,7 @@ export type UserCreateRequest = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
@@ -428,6 +430,7 @@ export type UserUpdateRequest = {
status?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
@@ -441,6 +444,7 @@ export type BulkUserItem = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata: Record<string, string>;

View File

@@ -1091,14 +1091,16 @@ email = "이메일"
email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직"
position_placeholder = "수석/책임/선임"
position = "직"
position_placeholder = "팀장/센터장"
role = "역할"
tenant = "테넌트"
tenant_global = "시스템 전역"

View File

@@ -8,6 +8,7 @@ type TenantFixture = {
description: string;
status: string;
parentId?: string;
config?: Record<string, unknown>;
memberCount: number;
createdAt: string;
updatedAt: string;
@@ -41,7 +42,7 @@ function user(id: string, name: string, companyCode: string) {
role: "user",
status: "active",
companyCode,
position: "사원",
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
};
@@ -125,3 +126,554 @@ test("org chart viewport pans with drag and zooms with the mouse wheel", async (
);
expect(scale).toBeGreaterThan(1);
});
test("org chart dashboard uses the full screen below the orgfront topbar", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
tenant("root", "Baron Group", "baron"),
tenant("engineering", "Engineering", "engineering", "root"),
],
users: [
user("u-root", "Root User", "baron"),
user("u-eng", "Engineering User", "engineering"),
],
}),
});
});
await page.goto("/chart?token=full-screen");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const metrics = await page.evaluate(() => {
const topbar = document
.querySelector('[data-testid="orgfront-topbar"]')
?.getBoundingClientRect();
const main = document
.querySelector('[data-testid="orgfront-main"]')
?.getBoundingClientRect();
const shell = document
.querySelector('[data-testid="orgchart-dashboard-shell"]')
?.getBoundingClientRect();
if (!topbar || !main || !shell) {
throw new Error("Missing org chart layout elements");
}
return {
innerHeight: window.innerHeight,
innerWidth: window.innerWidth,
mainTop: main.top,
shellBottom: shell.bottom,
shellLeft: shell.left,
shellRight: shell.right,
shellTop: shell.top,
topbarBottom: topbar.bottom,
};
});
expect(Math.abs(metrics.mainTop - metrics.topbarBottom)).toBeLessThanOrEqual(
1,
);
expect(metrics.shellTop).toBe(metrics.topbarBottom);
expect(metrics.shellLeft).toBeLessThanOrEqual(1);
expect(metrics.shellRight).toBeGreaterThanOrEqual(metrics.innerWidth - 1);
expect(metrics.shellBottom).toBeGreaterThanOrEqual(metrics.innerHeight - 1);
});
test("org chart non-shared title does not render the MH Dashboard eyebrow", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group");
});
await page.route("**/api/v1/admin/tenants**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [
{
...tenant("group", "Baron Group", "baron"),
type: "COMPANY_GROUP",
},
tenant("engineering", "Engineering", "engineering", "group"),
],
limit: 10000,
offset: 0,
total: 2,
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [user("u-eng", "Engineering User", "engineering")],
limit: 5000,
offset: 0,
total: 1,
}),
});
});
await page.goto("/chart");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
await expect(page.getByText("MH Dashboard", { exact: true })).toHaveCount(0);
});
test("org chart renders dense member nodes with calculated member columns", async ({
page,
}) => {
const denseUsers = Array.from({ length: 6 }, (_, index) =>
user(`u-dense-${index + 1}`, `Dense User ${index + 1}`, "baron"),
);
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [tenant("root", "Baron Group", "baron")],
users: denseUsers,
}),
});
});
await page.goto("/chart?token=dense-members");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const rootNode = page.locator('[data-testid="orgchart-node-root"]');
await expect(rootNode).toHaveAttribute("width", /[4-9]\d{2,}/);
await expect(rootNode.locator('[data-member-columns="2"]')).toBeVisible();
await expect(rootNode.getByText("Dense User 6")).toBeVisible();
});
test("public org chart hides internal and private tenants and renders org unit type", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("group", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
tenant("company", "삼안", "saman", "group"),
{
...tenant("open-team", "공개 팀", "open-team", "company"),
config: { orgUnitType: "팀", visibility: "public" },
},
{
...tenant("internal-team", "내부 팀", "internal-team", "company"),
config: { visibility: "internal" },
},
{
...tenant("private-team", "비공개 팀", "private-team", "company"),
config: { visibility: "private" },
},
tenant(
"private-child",
"비공개 하위",
"private-child",
"private-team",
),
],
users: [
user("u-open", "Open User", "open-team"),
user("u-internal", "Internal User", "internal-team"),
user("u-private", "Private User", "private-team"),
user("u-private-child", "Private Child User", "private-child"),
],
}),
});
});
await page.goto("/chart?token=tenant-visibility");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg.getByText("공개 팀", { exact: true })).toBeVisible();
await expect(svg.getByText("팀", { exact: true })).toBeVisible();
await expect(svg.getByText(/Open User/)).toBeVisible();
await expect(svg.getByText("내부 팀", { exact: true })).toHaveCount(0);
await expect(svg.getByText("Internal User", { exact: true })).toHaveCount(0);
await expect(svg.getByText("비공개 팀", { exact: true })).toHaveCount(0);
await expect(svg.getByText("Private User", { exact: true })).toHaveCount(0);
await expect(svg.getByText("비공개 하위", { exact: true })).toHaveCount(0);
await expect(
svg.getByText("Private Child User", { exact: true }),
).toHaveCount(0);
});
test("org chart colors hanmac family and nested baron company group separately", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("family", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
{
...tenant("baron-group", "Baron Group", "baron-group", "family"),
type: "COMPANY_GROUP",
},
{
...tenant("baron-company", "Baron Company", "baron", "baron-group"),
type: "COMPANY",
},
],
users: [user("u-baron", "Baron User", "baron")],
}),
});
});
await page.goto("/chart?token=baron-group-color");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg.getByText("Baron Group", { exact: true })).toBeVisible();
const colors = await page.evaluate(() => {
function headerColor(nodeId: string) {
const node = document.querySelector(
`[data-testid="orgchart-node-${nodeId}"]`,
);
const header = node?.querySelector("div > div");
return header ? window.getComputedStyle(header).backgroundColor : "";
}
return {
baronCompany: headerColor("baron-company"),
baronGroup: headerColor("baron-group"),
family: headerColor("family"),
};
});
expect(colors.family).toBe("rgb(0, 0, 0)");
expect(colors.baronGroup).toBe("rgb(0, 76, 191)");
expect(colors.baronCompany).toBe("rgb(0, 76, 191)");
});
test("org chart orders top organization choices by the hanmac family policy", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("family", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
{
...tenant("saman", "삼안", "saman", "family"),
type: "COMPANY",
},
{
...tenant("baron-group", "바론그룹", "baron-group", "family"),
type: "COMPANY_GROUP",
},
{
...tenant("hanmac", "한맥기술", "hanmac", "family"),
type: "COMPANY",
},
{
...tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"),
type: "ORGANIZATION",
},
],
users: [],
}),
});
});
await page.goto("/chart?token=org-selection-order");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const labels = await page
.getByTestId("orgchart-org-selector")
.locator("button")
.evaluateAll((buttons) =>
buttons.map((button) => button.textContent?.trim() ?? ""),
);
expect(labels.slice(0, 5)).toEqual([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
test("org chart compresses many sibling organizations and allows wide zoom out", async ({
page,
}) => {
const childTenants = Array.from({ length: 13 }, (_, index) =>
tenant(
`team-${index + 1}`,
`Team ${index + 1}`,
`team-${index + 1}`,
"root",
),
);
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [tenant("root", "Baron Group", "baron"), ...childTenants],
users: childTenants.map((child, index) =>
user(`u-team-${index + 1}`, `Team ${index + 1} User`, child.slug),
),
}),
});
});
await page.goto("/chart?token=wide-siblings");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const viewport = page.locator('[data-testid="orgchart-viewport"]');
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.getByText("Team 13", { exact: true })).toBeVisible();
await expect(svg.locator('foreignObject[data-node-id^="team-"]')).toHaveCount(
13,
);
await expect(
page.getByRole("button", { name: "조직: 한맥가족" }),
).toBeVisible();
await expect(page.getByText("배치", { exact: true })).toBeHidden();
await expect(page.getByRole("button", { name: "배치: 자동" })).toBeVisible();
await expect(page.getByText("연결", { exact: true })).toHaveCount(0);
await expect(page.getByText("상위연결", { exact: true })).toHaveCount(0);
const autoChildYPositions = await svg
.locator('foreignObject[data-node-id^="team-"]')
.evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute("y") ?? "")
.filter((value) => value.length > 0),
);
expect(new Set(autoChildYPositions).size).toBeGreaterThan(1);
await expect(svg.locator("path")).toHaveCount(13);
await expect(
svg.locator('path:not([data-hidden-default="true"])'),
).toHaveCount(4);
await expect(svg.locator('path[data-hidden-default="true"]')).toHaveCount(9);
await svg.locator('foreignObject[data-node-id="team-13"]').hover();
await expect(svg.locator('path[data-highlighted="true"]')).toHaveCount(1);
await expect(svg.locator('path[data-muted="true"]')).toHaveCount(4);
await page.getByTestId("orgchart-layout-mode-option").hover();
await expect(page.getByText("배치", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { exact: true, name: "자동" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Top-down" }).click();
await expect
.poll(async () =>
svg
.locator('foreignObject[data-node-id^="team-"]')
.evaluateAll(
(nodes) =>
new Set(
nodes
.map((node) => node.getAttribute("y") ?? "")
.filter((value) => value.length > 0),
).size,
),
)
.toBe(1);
await page.getByTestId("orgchart-layout-mode-option").hover();
await page.getByRole("button", { name: "3열" }).click();
const threeColumnPositions = await svg
.locator('foreignObject[data-node-id^="team-"]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
x: node.getAttribute("x") ?? "",
y: node.getAttribute("y") ?? "",
})),
);
expect(new Set(threeColumnPositions.map((position) => position.x)).size).toBe(
3,
);
expect(new Set(threeColumnPositions.map((position) => position.y)).size).toBe(
5,
);
await expect(svg.locator("path")).toHaveCount(13);
await expect(
svg.locator('path:not([data-hidden-default="true"])'),
).toHaveCount(3);
const box = await viewport.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, 2500);
await expect
.poll(async () =>
svg.evaluate((element) =>
Number.parseFloat(element.getAttribute("data-scale") ?? "1"),
),
)
.toBeLessThan(0.45);
});
test("org chart selects first and second depth organizations from company hover choices", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("group", "Baron Group", "baron"),
type: "COMPANY_GROUP",
},
{
...tenant("company", "Company A", "company-a", "group"),
type: "COMPANY",
},
tenant("department", "Department A", "department-a", "company"),
tenant("squad", "Squad A", "squad-a", "department"),
tenant("team", "Team A", "team-a", "squad"),
],
users: [
user("u-company", "Company User", "company-a"),
user("u-department", "Department User", "department-a"),
user("u-squad", "Squad User", "squad-a"),
user("u-team", "Team User", "team-a"),
],
}),
});
});
await page.goto("/chart?token=company-depth-filter");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
await expect(
page.getByRole("button", { name: "조직: 한맥가족" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Company A" })).toBeVisible();
await expect(page.getByText("하위범위", { exact: true })).toHaveCount(0);
await expect(page.getByText("조직", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Company A" }).click();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg.getByText("Team A", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "조직: Company A" }),
).toBeVisible();
await expect(
page
.getByTestId("orgchart-company-option-company")
.getByRole("button", { name: "Company A" }),
).toBeVisible();
const orgButtonColor = await page
.getByRole("button", { name: "조직: Company A" })
.evaluate((element) => window.getComputedStyle(element).backgroundColor);
const layoutButtonColor = await page
.getByRole("button", { name: "배치: 자동" })
.evaluate((element) => window.getComputedStyle(element).backgroundColor);
expect(orgButtonColor).not.toBe(layoutButtonColor);
await page.getByTestId("orgchart-company-option-company").hover();
await expect(svg.getByText("Department A", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "1뎁스 Department A" }).click();
await expect(
page.getByRole("button", { name: "조직: Department A" }),
).toBeVisible();
await expect(svg.getByText("Squad A", { exact: true })).toBeVisible();
await page.getByTestId("orgchart-company-option-company").hover();
await page.getByRole("button", { name: "2뎁스 Squad A" }).click();
await expect(
page.getByRole("button", { name: "조직: Squad A" }),
).toBeVisible();
await expect(svg.getByText("Squad A", { exact: true })).toBeVisible();
await expect(svg.getByText("Team A", { exact: true })).toBeVisible();
});
test("org chart uses semantic zoom to simplify deep nodes and restore labels on zoom in", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
tenant("root", "Baron Group", "baron"),
tenant("department", "Archive Department", "department", "root"),
tenant("division", "Archive Division", "division", "department"),
tenant("deep", "Archive Deep Team", "deep", "division"),
],
users: [
user("u-root", "Root User", "baron"),
user("u-department", "Department User", "department"),
user("u-division", "Division User", "division"),
user("u-deep", "Deep User", "deep"),
],
}),
});
});
await page.goto("/chart?token=semantic-zoom");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const viewport = page.locator('[data-testid="orgchart-viewport"]');
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
const deepNode = svg.locator('foreignObject[data-node-id="deep"]');
await expect(svg).toHaveAttribute("data-semantic-zoom", "detail");
await expect(deepNode.getByText("Archive Deep Team")).toBeVisible();
const box = await viewport.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, 4000);
await expect
.poll(async () => svg.getAttribute("data-semantic-zoom"))
.toBe("overview");
await expect(deepNode.getByText("Archive Deep Team")).toHaveCount(0);
await page.mouse.wheel(0, -4000);
await expect
.poll(async () => svg.getAttribute("data-semantic-zoom"))
.toBe("detail");
await expect(deepNode.getByText("Archive Deep Team")).toBeVisible();
});

View File

@@ -56,7 +56,7 @@ function user(
status: "active",
tenantSlug,
companyCode: tenantSlug,
position: "사원",
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
...overrides,
@@ -173,7 +173,8 @@ async function installOrgPickerApiMock(
user("user-platform", "Platform User", "platform", {
metadata: { employeeNumber: "EMP-9001", skill: "Kubernetes" },
jobTitle: "Platform Engineer",
position: "책임",
grade: "책임",
position: "팀장",
}),
user("user-sales", "Sales User", "sales"),
];
@@ -252,14 +253,64 @@ test("picker menu lets developers switch selection mode and selectable type", as
).toBeVisible();
});
test("picker displays user names with job title and position", async ({
test("picker defaults to the hanmac-family company-group when no tenant id is supplied", async ({
page,
}) => {
await page.unroute("**/api/v1/admin/tenants**");
await page.unroute("**/api/v1/admin/users**");
const tenants = [
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: tenants,
total: tenants.length,
limit: 10000,
offset: 0,
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [],
total: 0,
limit: 5000,
offset: 0,
}),
});
});
await page.goto(withShareToken("/picker"));
const picker = page.frameLocator("iframe");
await expect(picker.getByText("한맥가족", { exact: true })).toBeVisible();
await expect(picker.getByText("삼안", { exact: true })).toBeVisible();
await expect(picker.getByText("Wrong Group", { exact: true })).toHaveCount(0);
});
test("picker displays user names with grade and optional position", async ({
page,
}) => {
await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
await expect(
page.getByRole("button", {
name: "Platform User(Platform Engineer) 책임",
name: "Platform User 책임(팀장)",
}),
).toBeVisible();
});
@@ -319,17 +370,17 @@ test("embed preview menu updates the iframe picker source", async ({
).toBeVisible();
});
test("embed preview passes tenant id and custom dimensions through the picker url", async ({
test("embed preview passes tenant slug and custom dimensions through the picker url", async ({
page,
}) => {
await page.goto(withShareToken("/embed-preview"));
await page.getByLabel("tenant ID").fill("company-baron");
await page.getByLabel("tenant slug").fill("baron");
await page.getByLabel("임베딩 너비").fill("520");
await page.getByLabel("임베딩 높이").fill("480");
await expect(page.getByTestId("embed-preview-src")).toContainText(
"tenantId=company-baron",
"tenantSlug=baron",
);
await expect(page.getByTestId("embed-preview-src")).toContainText(
"width=520",
@@ -347,16 +398,16 @@ test("embed preview passes tenant id and custom dimensions through the picker ur
await expect(picker.getByText("Sales User")).toHaveCount(0);
});
test("embed picker scopes the tree by tenant id, hides users for tenant selection, and keeps direct members before child tenants", async ({
test("embed picker scopes the tree by tenant slug, hides users for tenant selection, and keeps direct members before child tenants", async ({
page,
}) => {
await page.goto(
withShareToken("/embed-preview?tenantId=company-baron&select=tenant"),
withShareToken("/embed-preview?tenantSlug=baron&select=tenant"),
);
await expect(page.getByLabel("tenant ID")).toHaveValue("company-baron");
await expect(page.getByLabel("tenant slug")).toHaveValue("baron");
await expect(page.getByTestId("embed-preview-src")).toContainText(
"tenantId=company-baron",
"tenantSlug=baron",
);
const picker = page.frameLocator("iframe");
@@ -599,7 +650,7 @@ test("embed picker includes descendants by default and can disable descendant in
await picker.getByLabel("Engineering 선택").check();
await expect(picker.getByLabel("Platform 선택")).toBeChecked();
await expect(
picker.getByLabel("Platform User(Platform Engineer) 책임 선택"),
picker.getByLabel("Platform User 책임(팀장) 선택"),
).toBeChecked();
await picker.getByRole("button", { name: "선택 완료" }).click();
@@ -617,7 +668,7 @@ test("embed picker includes descendants by default and can disable descendant in
await picker.getByLabel("Engineering 선택").check();
await expect(picker.getByLabel("Platform 선택")).not.toBeChecked();
await expect(
picker.getByLabel("Platform User(Platform Engineer) 책임 선택"),
picker.getByLabel("Platform User 책임(팀장) 선택"),
).not.toBeChecked();
await picker.getByRole("button", { name: "선택 완료" }).click();

View File

@@ -29,7 +29,7 @@ function user(id: string, name: string, companyCode: string) {
role: "user",
status: "active",
companyCode,
position: "사원",
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
};
@@ -84,9 +84,7 @@ test("org chart uses svg viewBox zoom for sharp vector rendering", async ({
const viewport = page.locator('[data-testid="orgchart-viewport"]');
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(
svg.locator("text", { hasText: "Engineering User" }),
).toBeVisible();
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
const initialViewBox = await svg.getAttribute("viewBox");
const transform = await page
@@ -142,24 +140,18 @@ test("org chart filters by Hanmac family and company while excluding hanmac.kr a
await expect(page.getByText("총 4명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(
svg.locator("text", { hasText: "Hidden Hanmac User" }),
).toHaveCount(0);
await expect(
svg.locator("text", { hasText: "Engineering User" }),
).toBeVisible();
await expect(svg.locator("text", { hasText: "Sales User" })).toBeVisible();
await expect(svg.getByText(/Hidden Hanmac User/)).toHaveCount(0);
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
await expect(svg.getByText("Sales User 사원")).toBeVisible();
await page.getByRole("button", { name: "Baron" }).click();
await expect(page.getByText("총 2명")).toBeVisible();
await expect(page.getByText("총 4명")).toHaveCount(0);
await expect(
svg.locator("text", { hasText: "Engineering User" }),
).toBeVisible();
await expect(svg.locator("text", { hasText: "Sales User" })).toHaveCount(0);
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
await expect(svg.getByText(/Sales User/)).toHaveCount(0);
});
test("org chart displays user names with job title and position", async ({
test("org chart displays user names with grade and optional position", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
@@ -176,7 +168,8 @@ test("org chart displays user names with job title and position", async ({
{
...user("u-eng", "Engineering User", "engineering"),
jobTitle: "Platform Engineer",
position: "책임",
grade: "책임",
position: "팀장",
},
],
}),
@@ -186,11 +179,7 @@ test("org chart displays user names with job title and position", async ({
await page.goto("/chart?token=display-name");
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(
svg.locator("text", {
hasText: "Engineering User(Platform Engineer) 책임",
}),
).toBeVisible();
await expect(svg.getByText("Engineering User 책임(팀장)")).toBeVisible();
});
test("org chart places multi-tenant users only on leaf memberships without duplicate rendering", async ({
@@ -313,8 +302,8 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
await expect(page.getByText("총 1명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.locator("text", { hasText: "Shared User" })).toHaveCount(1);
await expect(svg.locator("text").filter({ hasText: /^1$/ })).toHaveCount(4);
await expect(svg.getByText(/Shared User/)).toHaveCount(1);
await expect(svg.getByText(/^1$/)).toHaveCount(4);
});
test("org chart counts multi-leaf tenant users once in ancestor totals", async ({
@@ -355,8 +344,8 @@ test("org chart counts multi-leaf tenant users once in ancestor totals", async (
await expect(page.getByText("총 1명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.locator("text", { hasText: "Shared User" })).toHaveCount(2);
await expect(svg.locator("text").filter({ hasText: /^1$/ })).toHaveCount(5);
await expect(svg.getByText(/Shared User/)).toHaveCount(2);
await expect(svg.getByText(/^1$/)).toHaveCount(5);
});
test("org chart hides system global tenant members", async ({ page }) => {
@@ -389,8 +378,8 @@ test("org chart hides system global tenant members", async ({ page }) => {
await expect(page.getByText("총 1명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.locator("text", { hasText: "시스템 전역" })).toHaveCount(0);
await expect(svg.locator("text", { hasText: "Global Admin" })).toHaveCount(0);
await expect(svg.locator("text", { hasText: "System Admin" })).toHaveCount(0);
await expect(svg.locator("text", { hasText: "Baron User" })).toBeVisible();
await expect(svg.getByText(/시스템 전역/)).toHaveCount(0);
await expect(svg.getByText(/Global Admin/)).toHaveCount(0);
await expect(svg.getByText(/System Admin/)).toHaveCount(0);
await expect(svg.getByText("Baron User 사원")).toBeVisible();
});