forked from baron/baron-sso
조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인
This commit is contained in:
23
.playwright-mcp/page-2026-05-11T09-39-57-342Z.yml
Normal file
23
.playwright-mcp/page-2026-05-11T09-39-57-342Z.yml
Normal 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
50
adminfront/gpdtdc_org.csv
Normal 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)"
|
||||
|
50
adminfront/gpdtdc_org_slugged.csv
Normal file
50
adminfront/gpdtdc_org_slugged.csv
Normal 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)"
|
||||
|
44
adminfront/saman_org.csv
Normal file
44
adminfront/saman_org.csv
Normal 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)"
|
||||
|
44
adminfront/saman_org_slugged.csv
Normal file
44
adminfront/saman_org_slugged.csv
Normal 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)"
|
||||
|
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
60
adminfront/src/features/tenants/utils/orgConfig.test.ts
Normal file
60
adminfront/src/features/tenants/utils/orgConfig.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
92
adminfront/src/features/tenants/utils/orgConfig.ts
Normal file
92
adminfront/src/features/tenants/utils/orgConfig.ts
Normal 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;
|
||||
}
|
||||
@@ -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,,");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;",
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ test@test.com,Test,baron`;
|
||||
name: "John Doe",
|
||||
phone: "+19144812222",
|
||||
department: "myteam",
|
||||
grade: "Manager",
|
||||
position: "Manager",
|
||||
jobTitle: "Sales management",
|
||||
tenantImport: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "비밀번호 변경"
|
||||
|
||||
@@ -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: "파트장",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,6 +3,9 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
esbuild: {
|
||||
jsx: "automatic",
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -140,7 +140,7 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "admin",
|
||||
"grade": "",
|
||||
"role": domain.RoleSuperAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
255
backend/internal/handler/rp_manifest_handler.go
Normal file
255
backend/internal/handler/rp_manifest_handler.go
Normal 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:<baron_identity_id></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:<client_id>)
|
||||
check(User:abc, members, Tenant:<tenant_id>)
|
||||
check(User:abc, viewers, Resource:<resource_type>:<resource_id>)</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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
123
backend/internal/handler/rp_manifest_handler_test.go
Normal file
123
backend/internal/handler/rp_manifest_handler_test.go
Normal 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:<client_id>")
|
||||
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")
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
82
docs/rp-iam-integration-guide.md
Normal file
82
docs/rp-iam-integration-guide.md
Normal 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 처리해야 합니다.
|
||||
@@ -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`)를 보조 표시로 사용합니다.
|
||||
|
||||
### 공유 조직도 화면
|
||||
|
||||
|
||||
491
orgfront/package-lock.json
generated
491
orgfront/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
107
orgfront/src/features/orgchart/pickerTree.test.ts
Normal file
107
orgfront/src/features/orgchart/pickerTree.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
34
orgfront/src/features/orgchart/pickerTypes.test.ts
Normal file
34
orgfront/src/features/orgchart/pickerTypes.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
388
orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
Normal file
388
orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
Normal 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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
45
orgfront/src/features/orgchart/tenantVisibility.ts
Normal file
45
orgfront/src/features/orgchart/tenantVisibility.ts
Normal 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() : "";
|
||||
}
|
||||
49
orgfront/src/features/orgchart/userDisplay.test.ts
Normal file
49
orgfront/src/features/orgchart/userDisplay.test.ts
Normal 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("홍길동 수석(센터장)");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 = "시스템 전역"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user