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 { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { DomainTagInput } from "../components/DomainTagInput";
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
|
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||||
import {
|
import {
|
||||||
type ServerDomainConflict,
|
type ServerDomainConflict,
|
||||||
formatDomainConflictMessage,
|
formatDomainConflictMessage,
|
||||||
@@ -171,25 +172,17 @@ function TenantCreatePage() {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<ParentTenantSelector
|
||||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
id="parentId"
|
||||||
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
|
label={t(
|
||||||
</Label>
|
"ui.admin.tenants.create.form.parent",
|
||||||
<select
|
"상위 테넌트 (선택)",
|
||||||
id="parentId"
|
)}
|
||||||
name="parentId"
|
value={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"
|
onChange={setParentId}
|
||||||
value={parentId}
|
tenants={parentQuery.data?.items ?? []}
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
noneLabel={t("ui.common.none", "없음")}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ import { isSeedTenant } from "../utils/protectedTenants";
|
|||||||
import {
|
import {
|
||||||
type TenantImportPreviewRow,
|
type TenantImportPreviewRow,
|
||||||
type TenantImportResolution,
|
type TenantImportResolution,
|
||||||
|
buildTenantImportParentOptionGroups,
|
||||||
buildTenantImportPreview,
|
buildTenantImportPreview,
|
||||||
|
inferTenantImportRootParentSlug,
|
||||||
parseTenantCSV,
|
parseTenantCSV,
|
||||||
serializeTenantImportCSV,
|
serializeTenantImportCSV,
|
||||||
} from "../utils/tenantCsvImport";
|
} 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() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||||
@@ -114,6 +229,9 @@ function TenantListPage() {
|
|||||||
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
|
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
|
||||||
Record<number, string>
|
Record<number, string>
|
||||||
>({});
|
>({});
|
||||||
|
const [selectedParentRefs, setSelectedParentRefs] = React.useState<
|
||||||
|
Record<number, string>
|
||||||
|
>({});
|
||||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
@@ -189,6 +307,7 @@ function TenantListPage() {
|
|||||||
setPreviewOpen(false);
|
setPreviewOpen(false);
|
||||||
setPreviewRows([]);
|
setPreviewRows([]);
|
||||||
setSelectedMatches({});
|
setSelectedMatches({});
|
||||||
|
setSelectedParentRefs({});
|
||||||
query.refetch();
|
query.refetch();
|
||||||
},
|
},
|
||||||
onError: (error: AxiosError<{ error?: string }>) => {
|
onError: (error: AxiosError<{ error?: string }>) => {
|
||||||
@@ -234,6 +353,8 @@ function TenantListPage() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const allTenants = query.data?.items ?? [];
|
const allTenants = query.data?.items ?? [];
|
||||||
|
const importParentOptionGroups =
|
||||||
|
buildTenantImportParentOptionGroups(allTenants);
|
||||||
const tenants = React.useMemo(() => {
|
const tenants = React.useMemo(() => {
|
||||||
// 1. Calculate recursive counts
|
// 1. Calculate recursive counts
|
||||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||||
@@ -373,7 +494,9 @@ function TenantListPage() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setImportMessage("");
|
setImportMessage("");
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const rows = parseTenantCSV(text);
|
const rows = parseTenantCSV(text, {
|
||||||
|
rootParentSlug: inferTenantImportRootParentSlug(file.name, allTenants),
|
||||||
|
});
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
setImportMessage(
|
setImportMessage(
|
||||||
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
|
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
|
||||||
@@ -395,6 +518,14 @@ function TenantListPage() {
|
|||||||
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setSelectedParentRefs(
|
||||||
|
Object.fromEntries(
|
||||||
|
preview.map((row) => [
|
||||||
|
row.row.rowNumber,
|
||||||
|
resolveDefaultImportParentRef(row, preview, allTenants),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,7 +537,21 @@ function TenantListPage() {
|
|||||||
if (selected && selected !== "__create__") {
|
if (selected && selected !== "__create__") {
|
||||||
return [
|
return [
|
||||||
preview.row.rowNumber,
|
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:
|
slug:
|
||||||
selectedCreateSlugs[preview.row.rowNumber] ||
|
selectedCreateSlugs[preview.row.rowNumber] ||
|
||||||
preview.defaultCreateSlug,
|
preview.defaultCreateSlug,
|
||||||
|
...resolveImportParentSelection(
|
||||||
|
selectedParentRefs[preview.row.rowNumber] ??
|
||||||
|
resolveDefaultImportParentRef(
|
||||||
|
preview,
|
||||||
|
previewRows,
|
||||||
|
allTenants,
|
||||||
|
),
|
||||||
|
previewRows,
|
||||||
|
selectedMatches,
|
||||||
|
selectedCreateSlugs,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
@@ -860,6 +1016,9 @@ function TenantListPage() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.tenants.import_preview.parent", "상위")}
|
||||||
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.import_preview.match", "매칭")}
|
{t("ui.admin.tenants.import_preview.match", "매칭")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -909,6 +1068,94 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -24,10 +24,20 @@ import {
|
|||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { DomainTagInput } from "../components/DomainTagInput";
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
|
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||||
import {
|
import {
|
||||||
type ServerDomainConflict,
|
type ServerDomainConflict,
|
||||||
formatDomainConflictMessage,
|
formatDomainConflictMessage,
|
||||||
} from "../utils/domainTags";
|
} 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";
|
import { isSeedTenant } from "../utils/protectedTenants";
|
||||||
|
|
||||||
export function TenantProfilePage() {
|
export function TenantProfilePage() {
|
||||||
@@ -51,9 +61,6 @@ export function TenantProfilePage() {
|
|||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchTenants(1000, 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableParents =
|
|
||||||
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [type, setType] = useState("COMPANY");
|
const [type, setType] = useState("COMPANY");
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
@@ -64,9 +71,13 @@ export function TenantProfilePage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [parentId, setParentId] = useState("");
|
const [parentId, setParentId] = useState("");
|
||||||
|
const [orgUnitType, setOrgUnitType] = useState("");
|
||||||
|
const [tenantVisibility, setTenantVisibility] =
|
||||||
|
useState<TenantVisibility>("public");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantQuery.data) {
|
if (tenantQuery.data) {
|
||||||
|
const orgConfig = readTenantOrgConfig(tenantQuery.data.config);
|
||||||
setName(tenantQuery.data.name);
|
setName(tenantQuery.data.name);
|
||||||
setType(tenantQuery.data.type || "COMPANY");
|
setType(tenantQuery.data.type || "COMPANY");
|
||||||
setSlug(tenantQuery.data.slug);
|
setSlug(tenantQuery.data.slug);
|
||||||
@@ -75,12 +86,37 @@ export function TenantProfilePage() {
|
|||||||
setDomains(tenantQuery.data.domains ?? []);
|
setDomains(tenantQuery.data.domains ?? []);
|
||||||
setForceDomainConflicts([]);
|
setForceDomainConflicts([]);
|
||||||
setParentId(tenantQuery.data.parentId ?? "");
|
setParentId(tenantQuery.data.parentId ?? "");
|
||||||
|
setOrgUnitType(orgConfig.orgUnitType);
|
||||||
|
setTenantVisibility(orgConfig.visibility);
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [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({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (overrideForceDomains?: string[]) =>
|
mutationFn: (overrideForceDomains?: string[]) => {
|
||||||
updateTenant(tenantId, {
|
const baseConfig = tenantQuery.data?.config;
|
||||||
|
const config = canEditOrgConfig
|
||||||
|
? mergeTenantOrgConfig(baseConfig, {
|
||||||
|
orgUnitType,
|
||||||
|
visibility: tenantVisibility,
|
||||||
|
})
|
||||||
|
: removeTenantOrgConfig(baseConfig);
|
||||||
|
|
||||||
|
return updateTenant(tenantId, {
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
slug,
|
slug,
|
||||||
@@ -89,7 +125,9 @@ export function TenantProfilePage() {
|
|||||||
parentId: parentId || undefined,
|
parentId: parentId || undefined,
|
||||||
domains,
|
domains,
|
||||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||||
}),
|
config,
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
@@ -250,31 +288,22 @@ export function TenantProfilePage() {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<ParentTenantSelector
|
||||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
id="parentId"
|
||||||
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
|
label={t(
|
||||||
</Label>
|
"ui.admin.tenants.profile.form.parent",
|
||||||
<select
|
"상위 테넌트 (선택)",
|
||||||
id="parentId"
|
)}
|
||||||
name="parentId"
|
value={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"
|
onChange={setParentId}
|
||||||
value={parentId}
|
tenants={parentQuery.data?.items ?? []}
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
noneLabel={t("ui.common.none", "없음 (최상위)")}
|
||||||
>
|
helpText={t(
|
||||||
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
|
"ui.admin.tenants.profile.form.parent_help",
|
||||||
{availableParents.map((t) => (
|
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||||
<option key={t.id} value={t.id}>
|
)}
|
||||||
{t.name} ({t.slug})
|
excludeTenantId={tenantId}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{t(
|
|
||||||
"ui.admin.tenants.profile.form.parent_help",
|
|
||||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||||
@@ -336,6 +365,45 @@ export function TenantProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{errorMsg && (
|
||||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
{errorMsg}
|
{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 type { TenantSummary } from "../../../lib/adminApi";
|
||||||
import {
|
import {
|
||||||
|
buildTenantImportParentOptionGroups,
|
||||||
buildTenantImportPreview,
|
buildTenantImportPreview,
|
||||||
|
inferTenantImportRootParentSlug,
|
||||||
parseTenantCSV,
|
parseTenantCSV,
|
||||||
serializeTenantImportCSV,
|
serializeTenantImportCSV,
|
||||||
} from "./tenantCsvImport";
|
} from "./tenantCsvImport";
|
||||||
@@ -31,9 +33,37 @@ const tenants: TenantSummary[] = [
|
|||||||
createdAt: "",
|
createdAt: "",
|
||||||
updatedAt: "",
|
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", () => {
|
describe("tenantCsvImport", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it("parses tenant CSV rows with the supported import columns", () => {
|
it("parses tenant CSV rows with the supported import columns", () => {
|
||||||
const rows = parseTenantCSV(
|
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",
|
"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(
|
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(
|
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");
|
expect(csv).not.toContain("local-tenant-id");
|
||||||
});
|
});
|
||||||
@@ -138,10 +168,10 @@ describe("tenantCsvImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(csv).toContain(
|
expect(csv).toContain(
|
||||||
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
|
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
|
||||||
);
|
);
|
||||||
expect(csv).toContain(
|
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-parent-id");
|
||||||
expect(csv).not.toContain("local-child-id");
|
expect(csv).not.toContain("local-child-id");
|
||||||
@@ -171,7 +201,157 @@ describe("tenantCsvImport", () => {
|
|||||||
|
|
||||||
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
||||||
expect(csv).toContain(
|
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;
|
emailDomain: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantCSVParseOptions = {
|
||||||
|
rootParentSlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
|
||||||
|
|
||||||
export type TenantImportCandidate = {
|
export type TenantImportCandidate = {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,6 +34,16 @@ export type TenantImportPreviewRow = {
|
|||||||
conflicts: TenantImportConflict[];
|
conflicts: TenantImportConflict[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantImportParentOptionGroupType =
|
||||||
|
| "COMPANY_GROUP"
|
||||||
|
| "COMPANY"
|
||||||
|
| "ORGANIZATION";
|
||||||
|
|
||||||
|
export type TenantImportParentOptionGroup = {
|
||||||
|
type: TenantImportParentOptionGroupType;
|
||||||
|
tenants: TenantSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
export type TenantImportConflict =
|
export type TenantImportConflict =
|
||||||
| "external_tenant_id"
|
| "external_tenant_id"
|
||||||
| "slug_exists"
|
| "slug_exists"
|
||||||
@@ -37,12 +53,15 @@ export type TenantImportResolution =
|
|||||||
| {
|
| {
|
||||||
mode: "existing";
|
mode: "existing";
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
parentTenantId?: string;
|
||||||
|
parentTenantSlug?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
mode: "create";
|
mode: "create";
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
parentTenantId?: string;
|
parentTenantId?: string;
|
||||||
|
parentTenantSlug?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
mode: "skip";
|
mode: "skip";
|
||||||
@@ -53,16 +72,18 @@ const importHeaders = [
|
|||||||
"name",
|
"name",
|
||||||
"type",
|
"type",
|
||||||
"parent_tenant_id",
|
"parent_tenant_id",
|
||||||
|
"parent_tenant_slug",
|
||||||
"slug",
|
"slug",
|
||||||
"memo",
|
"memo",
|
||||||
"email_domain",
|
"email_domain",
|
||||||
];
|
];
|
||||||
|
|
||||||
const headerAliases: Record<string, keyof TenantCSVRow> = {
|
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||||
id: "tenantId",
|
id: "tenantId",
|
||||||
tenantid: "tenantId",
|
tenantid: "tenantId",
|
||||||
tenant_id: "tenantId",
|
tenant_id: "tenantId",
|
||||||
name: "name",
|
name: "name",
|
||||||
|
조직명: "name",
|
||||||
type: "type",
|
type: "type",
|
||||||
parentid: "parentTenantId",
|
parentid: "parentTenantId",
|
||||||
parent_id: "parentTenantId",
|
parent_id: "parentTenantId",
|
||||||
@@ -70,9 +91,12 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
|
|||||||
parent_tenant_id: "parentTenantId",
|
parent_tenant_id: "parentTenantId",
|
||||||
parenttenantslug: "parentTenantSlug",
|
parenttenantslug: "parentTenantSlug",
|
||||||
parent_tenant_slug: "parentTenantSlug",
|
parent_tenant_slug: "parentTenantSlug",
|
||||||
|
상위_조직: "parentOrg",
|
||||||
slug: "slug",
|
slug: "slug",
|
||||||
memo: "memo",
|
memo: "memo",
|
||||||
description: "memo",
|
description: "memo",
|
||||||
|
설명: "memo",
|
||||||
|
메일링_리스트: "mailingList",
|
||||||
"email-domain": "emailDomain",
|
"email-domain": "emailDomain",
|
||||||
emaildomain: "emailDomain",
|
emaildomain: "emailDomain",
|
||||||
email_domain: "emailDomain",
|
email_domain: "emailDomain",
|
||||||
@@ -80,39 +104,96 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
|
|||||||
domains: "emailDomain",
|
domains: "emailDomain",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseTenantCSV(text: string): TenantCSVRow[] {
|
export function parseTenantCSV(
|
||||||
|
text: string,
|
||||||
|
options: TenantCSVParseOptions = {},
|
||||||
|
): TenantCSVRow[] {
|
||||||
const records = parseCSV(text.replace(/^\uFEFF/, ""));
|
const records = parseCSV(text.replace(/^\uFEFF/, ""));
|
||||||
if (records.length === 0) return [];
|
if (records.length === 0) return [];
|
||||||
|
|
||||||
const header = new Map<keyof TenantCSVRow, number>();
|
const header = new Map<TenantCSVSourceKey, number>();
|
||||||
records[0].forEach((column, index) => {
|
records[0].forEach((column, index) => {
|
||||||
const normalized = normalizeHeader(column);
|
const normalized = normalizeHeader(column);
|
||||||
const key = headerAliases[normalized];
|
const key = headerAliases[normalized];
|
||||||
if (key) header.set(key, index);
|
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 [];
|
if (record.every((value) => value.trim() === "")) return [];
|
||||||
const value = (key: keyof TenantCSVRow) => {
|
const value = (key: TenantCSVSourceKey) => {
|
||||||
const columnIndex = header.get(key);
|
const columnIndex = header.get(key);
|
||||||
if (columnIndex === undefined) return "";
|
if (columnIndex === undefined) return "";
|
||||||
return (record[columnIndex] ?? "").trim();
|
return (record[columnIndex] ?? "").trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
raw: record,
|
||||||
rowNumber: index + 2,
|
rowNumber: index + 2,
|
||||||
tenantId: value("tenantId"),
|
|
||||||
name: value("name"),
|
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"),
|
parentTenantId: value("parentTenantId"),
|
||||||
parentTenantSlug: value("parentTenantSlug"),
|
parentTenantSlug,
|
||||||
slug: value("slug"),
|
slug,
|
||||||
memo: value("memo"),
|
memo: value("memo"),
|
||||||
emailDomain: value("emailDomain"),
|
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(
|
export function buildTenantImportPreview(
|
||||||
rows: TenantCSVRow[],
|
rows: TenantCSVRow[],
|
||||||
tenants: TenantSummary[],
|
tenants: TenantSummary[],
|
||||||
@@ -169,27 +250,40 @@ export function serializeTenantImportCSV(
|
|||||||
typeof resolution === "object" && resolution.mode === "create"
|
typeof resolution === "object" && resolution.mode === "create"
|
||||||
? resolution.slug || preview.defaultCreateSlug
|
? resolution.slug || preview.defaultCreateSlug
|
||||||
: preview.row.slug;
|
: 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 =
|
const parentTenantId =
|
||||||
typeof resolution === "object" && resolution.mode === "create"
|
typeof resolution === "object"
|
||||||
? (resolution.parentTenantId ??
|
? hasParentTenantIdOverride
|
||||||
remapParentTenantId(
|
? resolution.parentTenantId || ""
|
||||||
preview.row.parentTenantId,
|
: remapParentTenantId(
|
||||||
preview.row.parentTenantSlug,
|
preview.row.parentTenantId,
|
||||||
targetTenantIds,
|
sourceParentTenantSlug,
|
||||||
))
|
targetTenantIds,
|
||||||
|
)
|
||||||
: preview.row.parentTenantId;
|
: preview.row.parentTenantId;
|
||||||
|
const parentTenantSlug = remapParentTenantSlug(
|
||||||
|
sourceParentTenantSlug,
|
||||||
|
targetTenantIds,
|
||||||
|
);
|
||||||
const tenantId =
|
const tenantId =
|
||||||
typeof resolution === "object" && resolution.mode === "create"
|
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
|
||||||
? (resolution.tenantId ??
|
selectedTenantId ??
|
||||||
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
|
preview.row.tenantId;
|
||||||
createTenantImportId())
|
|
||||||
: selectedTenantId || preview.row.tenantId;
|
|
||||||
|
|
||||||
lines.push([
|
lines.push([
|
||||||
tenantId,
|
tenantId,
|
||||||
preview.row.name,
|
preview.row.name,
|
||||||
preview.row.type,
|
preview.row.type,
|
||||||
parentTenantId,
|
parentTenantId,
|
||||||
|
parentTenantSlug,
|
||||||
slug,
|
slug,
|
||||||
preview.row.memo,
|
preview.row.memo,
|
||||||
preview.row.emailDomain,
|
preview.row.emailDomain,
|
||||||
@@ -202,8 +296,10 @@ function buildTargetTenantIds(
|
|||||||
previewRows: TenantImportPreviewRow[],
|
previewRows: TenantImportPreviewRow[],
|
||||||
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||||
) {
|
) {
|
||||||
|
const byRowNumber = new Map<number, string>();
|
||||||
const bySourceId = new Map<string, string>();
|
const bySourceId = new Map<string, string>();
|
||||||
const bySourceSlug = new Map<string, string>();
|
const bySourceSlug = new Map<string, string>();
|
||||||
|
const bySourceSlugToTargetSlug = new Map<string, string>();
|
||||||
|
|
||||||
for (const preview of previewRows) {
|
for (const preview of previewRows) {
|
||||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||||
@@ -217,24 +313,38 @@ function buildTargetTenantIds(
|
|||||||
: resolution.mode === "existing"
|
: resolution.mode === "existing"
|
||||||
? resolution.tenantId
|
? resolution.tenantId
|
||||||
: resolution.tenantId || createTenantImportId();
|
: 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) {
|
if (preview.row.tenantId) {
|
||||||
bySourceId.set(preview.row.tenantId, targetTenantId);
|
bySourceId.set(preview.row.tenantId, targetTenantId);
|
||||||
}
|
}
|
||||||
if (preview.row.slug) {
|
if (preview.row.slug) {
|
||||||
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
|
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(
|
function remapParentTenantId(
|
||||||
parentTenantId: string,
|
parentTenantId: string,
|
||||||
parentTenantSlug: string,
|
parentTenantSlug: string,
|
||||||
targetTenantIds: {
|
targetTenantIds: {
|
||||||
|
byRowNumber: Map<number, string>;
|
||||||
bySourceId: Map<string, string>;
|
bySourceId: Map<string, string>;
|
||||||
bySourceSlug: Map<string, string>;
|
bySourceSlug: Map<string, string>;
|
||||||
|
bySourceSlugToTargetSlug: Map<string, string>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (parentTenantId) {
|
if (parentTenantId) {
|
||||||
@@ -248,6 +358,20 @@ function remapParentTenantId(
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remapParentTenantSlug(
|
||||||
|
parentTenantSlug: string,
|
||||||
|
targetTenantIds: {
|
||||||
|
bySourceSlugToTargetSlug: Map<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!parentTenantSlug) return "";
|
||||||
|
return (
|
||||||
|
targetTenantIds.bySourceSlugToTargetSlug.get(
|
||||||
|
parentTenantSlug.toLowerCase(),
|
||||||
|
) ?? parentTenantSlug
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createTenantImportId() {
|
function createTenantImportId() {
|
||||||
if (globalThis.crypto?.randomUUID) {
|
if (globalThis.crypto?.randomUUID) {
|
||||||
return globalThis.crypto.randomUUID();
|
return globalThis.crypto.randomUUID();
|
||||||
@@ -377,6 +501,33 @@ function normalizeHeader(value: string) {
|
|||||||
return value.trim().toLowerCase().replaceAll(" ", "_");
|
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) {
|
function normalizeToken(value: string) {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ function createEmptyAppointment(): AppointmentDraft {
|
|||||||
tenantName: "",
|
tenantName: "",
|
||||||
tenantSlug: "",
|
tenantSlug: "",
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
grade: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
position: "",
|
position: "",
|
||||||
};
|
};
|
||||||
@@ -148,6 +149,7 @@ function UserCreatePage() {
|
|||||||
phone: "",
|
phone: "",
|
||||||
tenantSlug: searchParams.get("tenantSlug") || "",
|
tenantSlug: searchParams.get("tenantSlug") || "",
|
||||||
department: "",
|
department: "",
|
||||||
|
grade: "",
|
||||||
position: "",
|
position: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
metadata: {},
|
metadata: {},
|
||||||
@@ -379,6 +381,7 @@ function UserCreatePage() {
|
|||||||
}
|
}
|
||||||
payload.tenantSlug = data.tenantSlug;
|
payload.tenantSlug = data.tenantSlug;
|
||||||
payload.department = data.department;
|
payload.department = data.department;
|
||||||
|
payload.grade = data.grade;
|
||||||
payload.position = data.position;
|
payload.position = data.position;
|
||||||
payload.jobTitle = data.jobTitle;
|
payload.jobTitle = data.jobTitle;
|
||||||
}
|
}
|
||||||
@@ -411,6 +414,7 @@ function UserCreatePage() {
|
|||||||
tenantName: appointment.tenantName,
|
tenantName: appointment.tenantName,
|
||||||
isPrimary: appointment.isOwner,
|
isPrimary: appointment.isOwner,
|
||||||
isOwner: appointment.isOwner,
|
isOwner: appointment.isOwner,
|
||||||
|
grade: appointment.grade,
|
||||||
jobTitle: appointment.jobTitle,
|
jobTitle: appointment.jobTitle,
|
||||||
position: appointment.position,
|
position: appointment.position,
|
||||||
}));
|
}));
|
||||||
@@ -685,12 +689,20 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<Input
|
||||||
id="position"
|
id="position"
|
||||||
placeholder="수석/책임/선임"
|
placeholder="팀장/센터장"
|
||||||
{...register("position")}
|
{...register("position")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -709,9 +721,11 @@ function UserCreatePage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">소속별 직급/직무</p>
|
<p className="text-sm font-medium">
|
||||||
|
소속별 직급/직책/직무
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
테넌트별 조직장 여부, 직무, 직급을 입력합니다.
|
테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -778,9 +792,23 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="grid gap-3 sm:grid-cols-2"
|
className="grid gap-3 sm:grid-cols-3"
|
||||||
data-testid={`appointment-position-line-${index}`}
|
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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`appointment-job-title-${index}`}>
|
<Label htmlFor={`appointment-job-title-${index}`}>
|
||||||
직무
|
직무
|
||||||
@@ -797,7 +825,7 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`appointment-position-${index}`}>
|
<Label htmlFor={`appointment-position-${index}`}>
|
||||||
직급
|
직책
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`appointment-position-${index}`}
|
id={`appointment-position-${index}`}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ function createEmptyAppointment(): AppointmentDraft {
|
|||||||
tenantSlug: "",
|
tenantSlug: "",
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
grade: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
position: "",
|
position: "",
|
||||||
};
|
};
|
||||||
@@ -379,6 +380,7 @@ function UserDetailPage() {
|
|||||||
status: "active",
|
status: "active",
|
||||||
tenantSlug: "",
|
tenantSlug: "",
|
||||||
department: "",
|
department: "",
|
||||||
|
grade: "",
|
||||||
position: "",
|
position: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
metadata: {},
|
metadata: {},
|
||||||
@@ -622,6 +624,7 @@ function UserDetailPage() {
|
|||||||
)?.slug ||
|
)?.slug ||
|
||||||
"",
|
"",
|
||||||
department: user.department || "",
|
department: user.department || "",
|
||||||
|
grade: user.grade || "",
|
||||||
position: user.position || "",
|
position: user.position || "",
|
||||||
jobTitle: user.jobTitle || "",
|
jobTitle: user.jobTitle || "",
|
||||||
metadata:
|
metadata:
|
||||||
@@ -671,6 +674,7 @@ function UserDetailPage() {
|
|||||||
isOwner:
|
isOwner:
|
||||||
metadata.primaryTenantIsOwner === true &&
|
metadata.primaryTenantIsOwner === true &&
|
||||||
tenant.id === fallbackAppointment?.id,
|
tenant.id === fallbackAppointment?.id,
|
||||||
|
grade: user.grade,
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
}))
|
}))
|
||||||
@@ -683,6 +687,7 @@ function UserDetailPage() {
|
|||||||
tenantSlug: fallbackAppointment.slug,
|
tenantSlug: fallbackAppointment.slug,
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
isOwner: metadata.primaryTenantIsOwner === true,
|
isOwner: metadata.primaryTenantIsOwner === true,
|
||||||
|
grade: user.grade,
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
},
|
},
|
||||||
@@ -750,6 +755,7 @@ function UserDetailPage() {
|
|||||||
const tenant = await ensurePersonalTenant();
|
const tenant = await ensurePersonalTenant();
|
||||||
payload.tenantSlug = tenant.slug;
|
payload.tenantSlug = tenant.slug;
|
||||||
payload.department = undefined;
|
payload.department = undefined;
|
||||||
|
payload.grade = undefined;
|
||||||
payload.position = undefined;
|
payload.position = undefined;
|
||||||
payload.jobTitle = undefined;
|
payload.jobTitle = undefined;
|
||||||
payload.metadata = {
|
payload.metadata = {
|
||||||
@@ -771,6 +777,7 @@ function UserDetailPage() {
|
|||||||
tenantName: appointment.tenantName,
|
tenantName: appointment.tenantName,
|
||||||
isPrimary: appointment.isOwner,
|
isPrimary: appointment.isOwner,
|
||||||
isOwner: appointment.isOwner,
|
isOwner: appointment.isOwner,
|
||||||
|
grade: appointment.grade,
|
||||||
jobTitle: appointment.jobTitle,
|
jobTitle: appointment.jobTitle,
|
||||||
position: appointment.position,
|
position: appointment.position,
|
||||||
}));
|
}));
|
||||||
@@ -790,6 +797,7 @@ function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload.department = undefined;
|
payload.department = undefined;
|
||||||
|
payload.grade = undefined;
|
||||||
payload.position = undefined;
|
payload.position = undefined;
|
||||||
payload.jobTitle = undefined;
|
payload.jobTitle = undefined;
|
||||||
payload.additionalAppointments = appointments;
|
payload.additionalAppointments = appointments;
|
||||||
@@ -1142,13 +1150,13 @@ function UserDetailPage() {
|
|||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.form.additional_appointments",
|
"ui.admin.users.detail.form.additional_appointments",
|
||||||
"소속별 직급/직무",
|
"소속별 직급/직책/직무",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.form.additional_appointments_help",
|
"msg.admin.users.detail.form.additional_appointments_help",
|
||||||
"테넌트별 조직장 여부, 직무, 직급을 입력합니다.",
|
"테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1226,9 +1234,28 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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}`}
|
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">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`detail-appointment-job-title-${index}`}
|
htmlFor={`detail-appointment-job-title-${index}`}
|
||||||
@@ -1255,7 +1282,7 @@ function UserDetailPage() {
|
|||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.form.position",
|
"ui.admin.users.detail.form.position",
|
||||||
"직급",
|
"직책",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1313,12 +1340,25 @@ function UserDetailPage() {
|
|||||||
className="h-11 shadow-sm"
|
className="h-11 shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="position"
|
htmlFor="position"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("ui.admin.users.detail.form.position", "직급")}
|
{t("ui.admin.users.detail.form.position", "직책")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="position"
|
id="position"
|
||||||
|
|||||||
@@ -249,9 +249,9 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
|
|
||||||
const downloadTemplate = () => {
|
const downloadTemplate = () => {
|
||||||
const headers =
|
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 =
|
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}`], {
|
const blob = new Blob([`${headers}\n${example}`], {
|
||||||
type: "text/csv;charset=utf-8;",
|
type: "text/csv;charset=utf-8;",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ test@test.com,Test,baron`;
|
|||||||
name: "John Doe",
|
name: "John Doe",
|
||||||
phone: "+19144812222",
|
phone: "+19144812222",
|
||||||
department: "myteam",
|
department: "myteam",
|
||||||
|
grade: "Manager",
|
||||||
position: "Manager",
|
position: "Manager",
|
||||||
jobTitle: "Sales management",
|
jobTitle: "Sales management",
|
||||||
tenantImport: {
|
tenantImport: {
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
};
|
};
|
||||||
} else if (header === "department") {
|
} else if (header === "department") {
|
||||||
item.department = value;
|
item.department = value;
|
||||||
|
} else if (header === "grade") {
|
||||||
|
item.grade = value;
|
||||||
} else if (header === "position") {
|
} else if (header === "position") {
|
||||||
item.position = value;
|
item.position = value;
|
||||||
} else if (header === "jobtitle") {
|
} else if (header === "jobtitle") {
|
||||||
@@ -100,6 +102,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
} else if (header === "usertype") {
|
} else if (header === "usertype") {
|
||||||
item.metadata.naverworks_user_type = value;
|
item.metadata.naverworks_user_type = value;
|
||||||
} else if (header === "level") {
|
} else if (header === "level") {
|
||||||
|
item.grade = value;
|
||||||
item.metadata.naverworks_level = value;
|
item.metadata.naverworks_level = value;
|
||||||
} else if (header === "organization") {
|
} else if (header === "organization") {
|
||||||
item.metadata.naverworks_organization_path = value;
|
item.metadata.naverworks_organization_path = value;
|
||||||
@@ -247,7 +250,7 @@ function applyNaverWorksFallbacks(
|
|||||||
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
|
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.position && item.metadata.naverworks_level) {
|
if (!item.grade && item.metadata.naverworks_level) {
|
||||||
item.position = item.metadata.naverworks_level;
|
item.grade = item.metadata.naverworks_level;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,7 @@ export type UserSummary = {
|
|||||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -499,6 +500,7 @@ export type UserCreateRequest = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
primaryTenantId?: string;
|
primaryTenantId?: string;
|
||||||
@@ -523,6 +525,7 @@ export type UserUpdateRequest = {
|
|||||||
isAddTenant?: boolean;
|
isAddTenant?: boolean;
|
||||||
isRemoveTenant?: boolean;
|
isRemoveTenant?: boolean;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
primaryTenantId?: string;
|
primaryTenantId?: string;
|
||||||
@@ -539,6 +542,7 @@ export type UserAppointment = {
|
|||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -550,6 +554,7 @@ export type BulkUserItem = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
tenantImport?: {
|
tenantImport?: {
|
||||||
@@ -786,6 +791,7 @@ export async function bulkUpdateUsers(payload: {
|
|||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
|
grade?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
}) {
|
}) {
|
||||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
const requestPayload: typeof payload & { companyCode?: string } = {
|
||||||
|
|||||||
@@ -1118,6 +1118,8 @@ email = "Email"
|
|||||||
email_placeholder = "user@example.com"
|
email_placeholder = "user@example.com"
|
||||||
job_title = "Job Title"
|
job_title = "Job Title"
|
||||||
job_title_placeholder = "e.g. Frontend Developer"
|
job_title_placeholder = "e.g. Frontend Developer"
|
||||||
|
grade = "Grade"
|
||||||
|
grade_placeholder = "e.g. Senior"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
name_placeholder = "Name Placeholder"
|
name_placeholder = "Name Placeholder"
|
||||||
password = "Password"
|
password = "Password"
|
||||||
@@ -1125,7 +1127,7 @@ password_placeholder = "********"
|
|||||||
phone = "Phone number"
|
phone = "Phone number"
|
||||||
phone_placeholder = "010-1234-5678"
|
phone_placeholder = "010-1234-5678"
|
||||||
position = "Position"
|
position = "Position"
|
||||||
position_placeholder = "e.g. Senior"
|
position_placeholder = "e.g. Team Lead"
|
||||||
role = "Role"
|
role = "Role"
|
||||||
tenant = "Tenant"
|
tenant = "Tenant"
|
||||||
tenant_global = "Tenant Global"
|
tenant_global = "Tenant Global"
|
||||||
@@ -1147,6 +1149,8 @@ multi_title = "Per-tenant Profile Management"
|
|||||||
[ui.admin.users.detail.form]
|
[ui.admin.users.detail.form]
|
||||||
department = "Department"
|
department = "Department"
|
||||||
department_placeholder = "Department Placeholder"
|
department_placeholder = "Department Placeholder"
|
||||||
|
grade = "Grade"
|
||||||
|
grade_placeholder = "e.g. Senior"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
name_placeholder = "Name Placeholder"
|
name_placeholder = "Name Placeholder"
|
||||||
phone = "Phone number"
|
phone = "Phone number"
|
||||||
@@ -1155,6 +1159,8 @@ role = "Role"
|
|||||||
status = "Status"
|
status = "Status"
|
||||||
tenant = "Representative Affiliated Tenant"
|
tenant = "Representative Affiliated Tenant"
|
||||||
tenant_global = "Tenant Global"
|
tenant_global = "Tenant Global"
|
||||||
|
position = "Position"
|
||||||
|
position_placeholder = "e.g. Team Lead"
|
||||||
|
|
||||||
[ui.admin.users.detail.security]
|
[ui.admin.users.detail.security]
|
||||||
password = "Password"
|
password = "Password"
|
||||||
|
|||||||
@@ -1120,14 +1120,16 @@ email = "이메일"
|
|||||||
email_placeholder = "user@example.com"
|
email_placeholder = "user@example.com"
|
||||||
job_title = "직무"
|
job_title = "직무"
|
||||||
job_title_placeholder = "프론트엔드 개발"
|
job_title_placeholder = "프론트엔드 개발"
|
||||||
|
grade = "직급"
|
||||||
|
grade_placeholder = "수석/책임/선임"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
name_placeholder = "홍길동"
|
name_placeholder = "홍길동"
|
||||||
password = "비밀번호"
|
password = "비밀번호"
|
||||||
password_placeholder = "********"
|
password_placeholder = "********"
|
||||||
phone = "전화번호"
|
phone = "전화번호"
|
||||||
phone_placeholder = "010-1234-5678"
|
phone_placeholder = "010-1234-5678"
|
||||||
position = "직급"
|
position = "직책"
|
||||||
position_placeholder = "수석/책임/선임"
|
position_placeholder = "팀장/센터장"
|
||||||
role = "역할"
|
role = "역할"
|
||||||
tenant = "테넌트"
|
tenant = "테넌트"
|
||||||
tenant_global = "시스템 전역"
|
tenant_global = "시스템 전역"
|
||||||
@@ -1149,6 +1151,8 @@ multi_title = "테넌트별 프로필 관리"
|
|||||||
[ui.admin.users.detail.form]
|
[ui.admin.users.detail.form]
|
||||||
department = "부서"
|
department = "부서"
|
||||||
department_placeholder = "개발팀"
|
department_placeholder = "개발팀"
|
||||||
|
grade = "직급"
|
||||||
|
grade_placeholder = "수석/책임/선임"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
name_placeholder = "홍길동"
|
name_placeholder = "홍길동"
|
||||||
phone = "전화번호"
|
phone = "전화번호"
|
||||||
@@ -1157,6 +1161,8 @@ role = "역할"
|
|||||||
status = "상태"
|
status = "상태"
|
||||||
tenant = "대표 소속 테넌트"
|
tenant = "대표 소속 테넌트"
|
||||||
tenant_global = "시스템 전역"
|
tenant_global = "시스템 전역"
|
||||||
|
position = "직책"
|
||||||
|
position_placeholder = "팀장/센터장"
|
||||||
|
|
||||||
[ui.admin.users.detail.security]
|
[ui.admin.users.detail.security]
|
||||||
password = "비밀번호 변경"
|
password = "비밀번호 변경"
|
||||||
|
|||||||
@@ -569,6 +569,7 @@ test.describe("User Management", () => {
|
|||||||
await page.getByRole("switch", { name: /대표 조직/i }).click();
|
await page.getByRole("switch", { name: /대표 조직/i }).click();
|
||||||
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
||||||
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="name"]').fill("Family User");
|
||||||
await page.locator('input[name="email"]').fill("family@test.com");
|
await page.locator('input[name="email"]').fill("family@test.com");
|
||||||
@@ -585,8 +586,9 @@ test.describe("User Management", () => {
|
|||||||
tenantSlug: "tech-planning",
|
tenantSlug: "tech-planning",
|
||||||
tenantName: "기술기획",
|
tenantName: "기술기획",
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
|
grade: "책임",
|
||||||
jobTitle: "플랫폼 운영",
|
jobTitle: "플랫폼 운영",
|
||||||
position: "책임",
|
position: "팀장",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -705,8 +707,9 @@ test.describe("User Management", () => {
|
|||||||
tenantName: "기술기획",
|
tenantName: "기술기획",
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
|
grade: "책임",
|
||||||
jobTitle: "플랫폼 운영",
|
jobTitle: "플랫폼 운영",
|
||||||
position: "책임",
|
position: "팀장",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -767,16 +770,18 @@ test.describe("User Management", () => {
|
|||||||
tenantSlug: "tech-planning",
|
tenantSlug: "tech-planning",
|
||||||
tenantName: "기술기획",
|
tenantName: "기술기획",
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
|
grade: "책임",
|
||||||
jobTitle: "플랫폼 운영",
|
jobTitle: "플랫폼 운영",
|
||||||
position: "책임",
|
position: "팀장",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenantId: "hanmac-team-id",
|
tenantId: "hanmac-team-id",
|
||||||
tenantSlug: "hanmac-team",
|
tenantSlug: "hanmac-team",
|
||||||
tenantName: "한맥팀",
|
tenantName: "한맥팀",
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
grade: "선임",
|
||||||
jobTitle: "개발",
|
jobTitle: "개발",
|
||||||
position: "선임",
|
position: "파트장",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
esbuild: {
|
||||||
|
jsx: "automatic",
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|||||||
@@ -26,14 +26,12 @@ func main() {
|
|||||||
norm := domain.NormalizeRole(r)
|
norm := domain.NormalizeRole(r)
|
||||||
if norm != r && norm == domain.RoleUser {
|
if norm != r && norm == domain.RoleUser {
|
||||||
traits["role"] = norm
|
traits["role"] = norm
|
||||||
traits["grade"] = norm
|
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
} else if g, ok := traits["grade"].(string); ok {
|
} else if g, ok := traits["grade"].(string); ok {
|
||||||
norm := domain.NormalizeRole(g)
|
if norm, ok := domain.NormalizeRoleAlias(g); ok {
|
||||||
if norm != g && norm == domain.RoleUser {
|
|
||||||
traits["role"] = norm
|
traits["role"] = norm
|
||||||
traits["grade"] = norm
|
delete(traits, "grade")
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,6 +587,10 @@ func main() {
|
|||||||
"checks": checks,
|
"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 Group
|
||||||
api := app.Group("/api/v1")
|
api := app.Group("/api/v1")
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
|
|||||||
"department": "Admin",
|
"department": "Admin",
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"companyCode": "",
|
"companyCode": "",
|
||||||
"grade": "admin",
|
"grade": "",
|
||||||
"role": domain.RoleSuperAdmin,
|
"role": domain.RoleSuperAdmin,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
|
|||||||
"department": "Admin",
|
"department": "Admin",
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"companyCode": "",
|
"companyCode": "",
|
||||||
"grade": "admin",
|
"grade": "",
|
||||||
"role": "super_admin", // Explicitly set role for Kratos traits
|
"role": "super_admin", // Explicitly set role for Kratos traits
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,20 +28,25 @@ const (
|
|||||||
|
|
||||||
// NormalizeRole maps legacy/synonym role values to canonical role keys.
|
// NormalizeRole maps legacy/synonym role values to canonical role keys.
|
||||||
func NormalizeRole(role string) string {
|
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))
|
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case RoleSuperAdmin, RoleTenantAdmin, RoleRPAdmin, RoleUser:
|
case RoleSuperAdmin, RoleTenantAdmin, RoleRPAdmin, RoleUser:
|
||||||
return normalized
|
return normalized, true
|
||||||
case "tenant_member", "member":
|
case "tenant_member", "member":
|
||||||
return RoleUser
|
return RoleUser, true
|
||||||
case "admin", "tenantadmin", "tenant-admin":
|
case "admin", "tenantadmin", "tenant-admin":
|
||||||
return RoleTenantAdmin
|
return RoleTenantAdmin, true
|
||||||
case "superadmin", "super-admin":
|
case "superadmin", "super-admin":
|
||||||
return RoleSuperAdmin
|
return RoleSuperAdmin, true
|
||||||
default:
|
default:
|
||||||
// Default any other business title (팀장, 그룹장, etc.) to a regular user.
|
return "", false
|
||||||
// These should be mapped to JobTitle or Position instead.
|
|
||||||
return RoleUser
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +65,8 @@ type User struct {
|
|||||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||||
Department string `gorm:"column:department" json:"department"`
|
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"` // 직무 (예: 프론트엔드 개발, 기획)
|
JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||||
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||||
Status string `gorm:"column:status;default:'active'" json:"status"`
|
Status string `gorm:"column:status;default:'active'" json:"status"`
|
||||||
|
|||||||
@@ -782,8 +782,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
"department": req.Department,
|
"department": req.Department,
|
||||||
"affiliationType": req.AffiliationType,
|
"affiliationType": req.AffiliationType,
|
||||||
"companyCode": companyCode,
|
"companyCode": companyCode,
|
||||||
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
"grade": "",
|
||||||
"grade": "member",
|
"role": domain.RoleUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync all custom login IDs based on tenant schemas
|
// 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 != "" {
|
if department := extractTraitString(traits, "department"); department != "" {
|
||||||
localUser.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 != "" {
|
if position := extractTraitString(traits, "position"); position != "" {
|
||||||
localUser.Position = position
|
localUser.Position = position
|
||||||
}
|
}
|
||||||
@@ -7302,13 +7307,12 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
|
|||||||
localUser.RelyingPartyID = &relyingPartyID
|
localUser.RelyingPartyID = &relyingPartyID
|
||||||
}
|
}
|
||||||
|
|
||||||
role := extractTraitString(traits, "grade")
|
role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role"))
|
||||||
if role == "" {
|
if !ok {
|
||||||
role = extractTraitString(traits, "role")
|
role, ok = domain.NormalizeRoleAlias(extractTraitString(traits, "grade"))
|
||||||
}
|
if !ok {
|
||||||
role = domain.NormalizeRole(role)
|
role = domain.RoleUser
|
||||||
if role == "" {
|
}
|
||||||
role = domain.RoleUser
|
|
||||||
}
|
}
|
||||||
localUser.Role = role
|
localUser.Role = role
|
||||||
if localUser.Status == "" {
|
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 {
|
if err != nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
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
|
normalized[key] = fields
|
||||||
continue
|
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
|
normalized[key] = value
|
||||||
}
|
}
|
||||||
return normalized, nil
|
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) {
|
func normalizeTenantUserSchema(value any) ([]any, error) {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -1023,6 +1159,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
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
|
tenant.Config = config
|
||||||
h.DB.Save(tenant)
|
h.DB.Save(tenant)
|
||||||
summary.Config = tenant.Config
|
summary.Config = tenant.Config
|
||||||
@@ -1162,6 +1307,15 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
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
|
tenant.Config = config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,10 +1850,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
|||||||
for _, t := range allTenants {
|
for _, t := range allTenants {
|
||||||
if findRoot(t.ID) == sharedRootID {
|
if findRoot(t.ID) == sharedRootID {
|
||||||
filteredTenants = append(filteredTenants, t)
|
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 {
|
type publicUserSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -610,6 +610,52 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
|
|||||||
mockSvc.AssertExpectations(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) {
|
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
|
||||||
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
|
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")
|
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) {
|
func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
|
|||||||
return false, false
|
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 {
|
type userSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -158,6 +179,7 @@ type userSummary struct {
|
|||||||
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
||||||
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
|
Grade string `json:"grade"`
|
||||||
Position string `json:"position"`
|
Position string `json:"position"`
|
||||||
JobTitle string `json:"jobTitle"`
|
JobTitle string `json:"jobTitle"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
@@ -429,6 +451,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
CompanyCode string `json:"companyCode"`
|
CompanyCode string `json:"companyCode"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
|
Grade string `json:"grade"`
|
||||||
Position string `json:"position"`
|
Position string `json:"position"`
|
||||||
JobTitle string `json:"jobTitle"`
|
JobTitle string `json:"jobTitle"`
|
||||||
PrimaryTenantID string `json:"primaryTenantId"`
|
PrimaryTenantID string `json:"primaryTenantId"`
|
||||||
@@ -488,11 +511,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
"department": req.Department,
|
"department": req.Department,
|
||||||
|
"grade": strings.TrimSpace(req.Grade),
|
||||||
"position": req.Position,
|
"position": req.Position,
|
||||||
"jobTitle": req.JobTitle,
|
"jobTitle": req.JobTitle,
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"companyCode": req.CompanyCode,
|
"companyCode": req.CompanyCode,
|
||||||
"grade": role,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Override with explicit LoginID if provided]
|
// [Override with explicit LoginID if provided]
|
||||||
@@ -648,6 +671,7 @@ type bulkUserItem struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
TenantSlug string `json:"tenantSlug"`
|
TenantSlug string `json:"tenantSlug"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
|
Grade string `json:"grade"`
|
||||||
Position string `json:"position"`
|
Position string `json:"position"`
|
||||||
JobTitle string `json:"jobTitle"`
|
JobTitle string `json:"jobTitle"`
|
||||||
Metadata map[string]any `json:"metadata"`
|
Metadata map[string]any `json:"metadata"`
|
||||||
@@ -820,12 +844,12 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
"department": dept,
|
"department": dept,
|
||||||
|
"grade": strings.TrimSpace(item.Grade),
|
||||||
"position": strings.TrimSpace(item.Position),
|
"position": strings.TrimSpace(item.Position),
|
||||||
"jobTitle": strings.TrimSpace(item.JobTitle),
|
"jobTitle": strings.TrimSpace(item.JobTitle),
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"companyCode": tenantSlug,
|
"companyCode": tenantSlug,
|
||||||
"tenant_id": tItem.ID,
|
"tenant_id": tItem.ID,
|
||||||
"grade": role,
|
|
||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,6 +913,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
Status: "active",
|
Status: "active",
|
||||||
CompanyCode: tenantSlug,
|
CompanyCode: tenantSlug,
|
||||||
Department: dept,
|
Department: dept,
|
||||||
|
Grade: strings.TrimSpace(item.Grade),
|
||||||
AffiliationType: "internal",
|
AffiliationType: "internal",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -1059,9 +1084,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Header row
|
// Header row
|
||||||
includeIDs := includeCSVIds(c)
|
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 {
|
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
|
// Collect all possible metadata keys for dynamic columns
|
||||||
@@ -1096,6 +1121,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
u.Phone,
|
u.Phone,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.CompanyCode,
|
u.CompanyCode,
|
||||||
|
u.Grade,
|
||||||
u.Position,
|
u.Position,
|
||||||
u.JobTitle,
|
u.JobTitle,
|
||||||
u.CreatedAt.Format(time.RFC3339),
|
u.CreatedAt.Format(time.RFC3339),
|
||||||
@@ -1109,6 +1135,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
u.Status,
|
u.Status,
|
||||||
tenantID,
|
tenantID,
|
||||||
u.CompanyCode,
|
u.CompanyCode,
|
||||||
|
u.Grade,
|
||||||
u.Position,
|
u.Position,
|
||||||
u.JobTitle,
|
u.JobTitle,
|
||||||
u.CreatedAt.Format(time.RFC3339),
|
u.CreatedAt.Format(time.RFC3339),
|
||||||
@@ -1142,6 +1169,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
CompanyCode *string `json:"companyCode"`
|
CompanyCode *string `json:"companyCode"`
|
||||||
Department *string `json:"department"`
|
Department *string `json:"department"`
|
||||||
|
Grade *string `json:"grade"`
|
||||||
Position *string `json:"position"`
|
Position *string `json:"position"`
|
||||||
JobTitle *string `json:"jobTitle"`
|
JobTitle *string `json:"jobTitle"`
|
||||||
}
|
}
|
||||||
@@ -1233,6 +1261,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
if req.Department != nil {
|
if req.Department != nil {
|
||||||
traits["department"] = *req.Department
|
traits["department"] = *req.Department
|
||||||
}
|
}
|
||||||
|
if req.Grade != nil {
|
||||||
|
traits["grade"] = *req.Grade
|
||||||
|
}
|
||||||
if req.Position != nil {
|
if req.Position != nil {
|
||||||
traits["position"] = *req.Position
|
traits["position"] = *req.Position
|
||||||
}
|
}
|
||||||
@@ -1258,7 +1289,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
// Sync to local DB
|
// Sync to local DB
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
localUser := h.mapToLocalUser(*identity)
|
localUser := h.mapToLocalUser(*identity)
|
||||||
oldRole := extractTraitString(identity.Traits, "grade")
|
oldRole := roleFromTraits(identity.Traits)
|
||||||
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
|
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
|
||||||
|
|
||||||
if req.Role != nil {
|
if req.Role != nil {
|
||||||
@@ -1437,6 +1468,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
IsAddTenant bool `json:"isAddTenant"`
|
IsAddTenant bool `json:"isAddTenant"`
|
||||||
IsRemoveTenant bool `json:"isRemoveTenant"`
|
IsRemoveTenant bool `json:"isRemoveTenant"`
|
||||||
Department *string `json:"department"`
|
Department *string `json:"department"`
|
||||||
|
Grade *string `json:"grade"`
|
||||||
Position *string `json:"position"`
|
Position *string `json:"position"`
|
||||||
JobTitle *string `json:"jobTitle"`
|
JobTitle *string `json:"jobTitle"`
|
||||||
PrimaryTenantID string `json:"primaryTenantId"`
|
PrimaryTenantID string `json:"primaryTenantId"`
|
||||||
@@ -1658,6 +1690,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if req.Department != nil {
|
if req.Department != nil {
|
||||||
traits["department"] = strings.TrimSpace(*req.Department)
|
traits["department"] = strings.TrimSpace(*req.Department)
|
||||||
}
|
}
|
||||||
|
if req.Grade != nil {
|
||||||
|
traits["grade"] = strings.TrimSpace(*req.Grade)
|
||||||
|
}
|
||||||
if req.Position != nil {
|
if req.Position != nil {
|
||||||
traits["position"] = strings.TrimSpace(*req.Position)
|
traits["position"] = strings.TrimSpace(*req.Position)
|
||||||
}
|
}
|
||||||
@@ -1669,7 +1704,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if role == "" {
|
if role == "" {
|
||||||
role = domain.RoleUser
|
role = domain.RoleUser
|
||||||
}
|
}
|
||||||
traits["grade"] = role
|
|
||||||
traits["role"] = role
|
traits["role"] = role
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1757,7 +1791,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
go func() {
|
go func() {
|
||||||
bgCtx := context.Background()
|
bgCtx := context.Background()
|
||||||
h.syncKetoRole(bgCtx, updatedLocalUser.ID,
|
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
|
// Try to automatically sync UserGroup membership based on Department
|
||||||
if h.UserGroupRepo != nil && h.KetoOutboxRepo != nil {
|
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 {
|
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := extractTraitString(traits, "grade")
|
role := roleFromTraits(traits)
|
||||||
if role == "" {
|
|
||||||
role = extractTraitString(traits, "role")
|
|
||||||
}
|
|
||||||
role = domain.NormalizeRole(role)
|
|
||||||
if role == "" {
|
|
||||||
role = domain.RoleUser
|
|
||||||
}
|
|
||||||
|
|
||||||
compCode := extractTraitString(traits, "companyCode")
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
|
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),
|
Status: normalizeStatus(identity.State),
|
||||||
CompanyCode: compCode,
|
CompanyCode: compCode,
|
||||||
Department: extractTraitString(traits, "department"),
|
Department: extractTraitString(traits, "department"),
|
||||||
|
Grade: gradeFromTraits(traits),
|
||||||
Position: extractTraitString(traits, "position"),
|
Position: extractTraitString(traits, "position"),
|
||||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||||
Metadata: make(domain.JSONMap),
|
Metadata: make(domain.JSONMap),
|
||||||
@@ -1997,14 +2025,7 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
|||||||
|
|
||||||
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := extractTraitString(traits, "grade")
|
role := roleFromTraits(traits)
|
||||||
if role == "" {
|
|
||||||
role = extractTraitString(traits, "role")
|
|
||||||
}
|
|
||||||
role = domain.NormalizeRole(role)
|
|
||||||
if role == "" {
|
|
||||||
role = domain.RoleUser
|
|
||||||
}
|
|
||||||
compCode := extractTraitString(traits, "companyCode")
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
if compCode == "" {
|
if compCode == "" {
|
||||||
compCode = extractTraitString(traits, "company_code")
|
compCode = extractTraitString(traits, "company_code")
|
||||||
@@ -2019,6 +2040,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
Status: normalizeStatus(identity.State),
|
Status: normalizeStatus(identity.State),
|
||||||
CompanyCode: compCode,
|
CompanyCode: compCode,
|
||||||
Department: extractTraitString(traits, "department"),
|
Department: extractTraitString(traits, "department"),
|
||||||
|
Grade: gradeFromTraits(traits),
|
||||||
Position: extractTraitString(traits, "position"),
|
Position: extractTraitString(traits, "position"),
|
||||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||||
AffiliationType: extractTraitString(traits, "affiliationType"),
|
AffiliationType: extractTraitString(traits, "affiliationType"),
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
|||||||
Status: "active",
|
Status: "active",
|
||||||
CompanyCode: "test-tenant",
|
CompanyCode: "test-tenant",
|
||||||
Department: "Legacy Department",
|
Department: "Legacy Department",
|
||||||
Position: "책임",
|
Grade: "책임",
|
||||||
|
Position: "팀장",
|
||||||
JobTitle: "플랫폼 운영",
|
JobTitle: "플랫폼 운영",
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
},
|
},
|
||||||
@@ -203,8 +204,8 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
|||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
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, "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.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, "Role")
|
||||||
assert.NotContains(t, body, "Department")
|
assert.NotContains(t, body, "Department")
|
||||||
mockRepo.AssertExpectations(t)
|
mockRepo.AssertExpectations(t)
|
||||||
@@ -235,7 +236,8 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
|||||||
Status: "active",
|
Status: "active",
|
||||||
CompanyCode: "test-tenant",
|
CompanyCode: "test-tenant",
|
||||||
TenantID: &tenantID,
|
TenantID: &tenantID,
|
||||||
Position: "책임",
|
Grade: "책임",
|
||||||
|
Position: "팀장",
|
||||||
JobTitle: "플랫폼 운영",
|
JobTitle: "플랫폼 운영",
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
},
|
},
|
||||||
@@ -248,8 +250,8 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
|||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
||||||
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Position,JobTitle,CreatedAt")
|
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.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant,책임,팀장")
|
||||||
assert.NotContains(t, body, "user-uuid")
|
assert.NotContains(t, body, "user-uuid")
|
||||||
assert.NotContains(t, body, "tenant-uuid")
|
assert.NotContains(t, body, "tenant-uuid")
|
||||||
assert.NotContains(t, body, "ID,")
|
assert.NotContains(t, body, "ID,")
|
||||||
@@ -1185,6 +1187,29 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
|||||||
mockOry.AssertExpectations(t)
|
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) {
|
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,13 +301,16 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
|||||||
updatedAt = now
|
updatedAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
role := userGroupTraitString(traits, "grade")
|
role, ok := domain.NormalizeRoleAlias(userGroupTraitString(traits, "role"))
|
||||||
if role == "" {
|
if !ok {
|
||||||
role = userGroupTraitString(traits, "role")
|
role, ok = domain.NormalizeRoleAlias(userGroupTraitString(traits, "grade"))
|
||||||
|
if !ok {
|
||||||
|
role = domain.RoleUser
|
||||||
|
}
|
||||||
}
|
}
|
||||||
role = domain.NormalizeRole(role)
|
grade := userGroupTraitString(traits, "grade")
|
||||||
if role == "" {
|
if _, ok := domain.NormalizeRoleAlias(grade); ok {
|
||||||
role = domain.RoleUser
|
grade = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
companyCode := userGroupTraitString(traits, "companyCode")
|
companyCode := userGroupTraitString(traits, "companyCode")
|
||||||
@@ -324,6 +327,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
|||||||
Status: userGroupIdentityStatus(identity.State),
|
Status: userGroupIdentityStatus(identity.State),
|
||||||
CompanyCode: companyCode,
|
CompanyCode: companyCode,
|
||||||
Department: userGroupTraitString(traits, "department"),
|
Department: userGroupTraitString(traits, "department"),
|
||||||
|
Grade: grade,
|
||||||
Position: userGroupTraitString(traits, "position"),
|
Position: userGroupTraitString(traits, "position"),
|
||||||
JobTitle: userGroupTraitString(traits, "jobTitle"),
|
JobTitle: userGroupTraitString(traits, "jobTitle"),
|
||||||
AffiliationType: userGroupTraitString(traits, "affiliationType"),
|
AffiliationType: userGroupTraitString(traits, "affiliationType"),
|
||||||
|
|||||||
@@ -61,13 +61,16 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
|||||||
updatedAt = now
|
updatedAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
role := kratosProjectionTraitString(traits, "grade")
|
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
|
||||||
if role == "" {
|
if !ok {
|
||||||
role = kratosProjectionTraitString(traits, "role")
|
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
|
||||||
|
if !ok {
|
||||||
|
role = domain.RoleUser
|
||||||
|
}
|
||||||
}
|
}
|
||||||
role = domain.NormalizeRole(role)
|
grade := kratosProjectionTraitString(traits, "grade")
|
||||||
if role == "" {
|
if _, ok := domain.NormalizeRoleAlias(grade); ok {
|
||||||
role = domain.RoleUser
|
grade = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
companyCode := kratosProjectionTraitString(traits, "companyCode")
|
companyCode := kratosProjectionTraitString(traits, "companyCode")
|
||||||
@@ -85,6 +88,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
|||||||
CompanyCode: companyCode,
|
CompanyCode: companyCode,
|
||||||
CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")),
|
CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")),
|
||||||
Department: kratosProjectionTraitString(traits, "department"),
|
Department: kratosProjectionTraitString(traits, "department"),
|
||||||
|
Grade: grade,
|
||||||
Position: kratosProjectionTraitString(traits, "position"),
|
Position: kratosProjectionTraitString(traits, "position"),
|
||||||
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
|
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
|
||||||
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),
|
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 | 주요 사용 필드 |
|
| 용도 | API | 주요 사용 필드 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 테넌트 목록 | `GET /v1/admin/tenants?limit=10000&offset=0` | `id`, `type`, `name`, `slug`, `parentId`, `memberCount`, `status` |
|
| 테넌트 목록 | `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"` 테넌트로 구성됩니다.
|
테넌트는 Baron Admin에서 입력한 `parentId` 관계를 기준으로 트리로 변환합니다. 현재 루트 후보는 `type === "COMPANY_GROUP"`인 테넌트를 우선 사용하고, 없으면 최상위 테넌트를 사용합니다. 회사 필터는 루트 하위의 `type === "COMPANY"` 테넌트로 구성됩니다.
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organizat
|
|||||||
5. `joinedTenants`가 있으면 각 joined tenant의 `slug`에도 같은 사용자를 추가합니다.
|
5. `joinedTenants`가 있으면 각 joined tenant의 `slug`에도 같은 사용자를 추가합니다.
|
||||||
6. 같은 테넌트 노드 안에서 동일 사용자 `id`는 중복 추가하지 않습니다.
|
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",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"@tanstack/react-query-devtools": "^5.66.8",
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -433,38 +434,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.1",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.0",
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.1",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@@ -527,9 +525,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
|
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -584,9 +582,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.122.0",
|
"version": "0.129.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
|
||||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -1058,9 +1056,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
|
||||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1075,9 +1073,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
|
||||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1092,9 +1090,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1109,9 +1107,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
|
||||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1126,9 +1124,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
|
||||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1143,13 +1141,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
|
||||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1160,13 +1161,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
|
||||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1177,13 +1181,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
|
||||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1194,13 +1201,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
|
||||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1211,13 +1221,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
|
||||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1228,13 +1241,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
|
||||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1245,9 +1261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
|
||||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1262,9 +1278,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
|
||||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -1272,16 +1288,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"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": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
|
||||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1296,9 +1314,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
|
||||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1380,9 +1398,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1401,6 +1419,55 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -1584,6 +1651,38 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -1676,14 +1775,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.5",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
@@ -1873,6 +1972,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1961,6 +2066,111 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -2203,9 +2413,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -3051,9 +3261,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3116,9 +3326,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3279,10 +3489,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@@ -3463,14 +3676,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
|
||||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.122.0",
|
"@oxc-project/types": "=0.129.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
"@rolldown/pluginutils": "1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -3479,27 +3692,27 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
"@rolldown/binding-android-arm64": "1.0.0",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
"@rolldown/binding-darwin-arm64": "1.0.0",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
"@rolldown/binding-darwin-x64": "1.0.0",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
"@rolldown/binding-freebsd-x64": "1.0.0",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
"@rolldown/binding-linux-arm64-musl": "1.0.0",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
"@rolldown/binding-linux-x64-gnu": "1.0.0",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
"@rolldown/binding-linux-x64-musl": "1.0.0",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
"@rolldown/binding-openharmony-arm64": "1.0.0",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
"@rolldown/binding-wasm32-wasi": "1.0.0",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
|
||||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -3719,14 +3932,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -3754,9 +3967,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3929,17 +4142,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
|
||||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.14",
|
||||||
"rolldown": "1.0.0-rc.12",
|
"rolldown": "1.0.0",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.16"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -3955,8 +4168,8 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.0",
|
"@vitejs/devtools": "^0.1.18",
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
@@ -4227,6 +4440,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"@tanstack/react-query-devtools": "^5.66.8",
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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 { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||||
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
|
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
|
||||||
import type { OrgPickerTreeNode } from "./pickerTypes";
|
import type { OrgPickerTreeNode } from "./pickerTypes";
|
||||||
|
import { filterTenantsByVisibility } from "./tenantVisibility";
|
||||||
import { getOrgChartUserDisplayName } from "./userDisplay";
|
import { getOrgChartUserDisplayName } from "./userDisplay";
|
||||||
|
|
||||||
function getUserTenantSlug(user: UserSummary) {
|
function getUserTenantSlug(user: UserSummary) {
|
||||||
@@ -28,6 +29,23 @@ function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
|
|||||||
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
|
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(
|
function tenantToPickerNode(
|
||||||
tenant: TenantNode,
|
tenant: TenantNode,
|
||||||
usersBySlug: Map<string, UserSummary[]>,
|
usersBySlug: Map<string, UserSummary[]>,
|
||||||
@@ -58,12 +76,34 @@ function tenantToPickerNode(
|
|||||||
|
|
||||||
function findTenantNode(
|
function findTenantNode(
|
||||||
roots: TenantNode[],
|
roots: TenantNode[],
|
||||||
tenantId: string,
|
tenantRef: string,
|
||||||
): TenantNode | undefined {
|
): 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) {
|
for (const root of roots) {
|
||||||
if (root.id === tenantId) return root;
|
const slugMatch = findBySlug(root);
|
||||||
const child = findTenantNode(root.children, tenantId);
|
if (slugMatch) return slugMatch;
|
||||||
if (child) return child;
|
}
|
||||||
|
for (const root of roots) {
|
||||||
|
const idMatch = findById(root);
|
||||||
|
if (idMatch) return idMatch;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -79,7 +119,10 @@ export function buildOrgPickerTree({
|
|||||||
rootTenantId?: string;
|
rootTenantId?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
}) {
|
}) {
|
||||||
const visibleTenants = tenants.filter(isOrgFrontTenantType);
|
const visibleTenants = filterTenantsByVisibility(
|
||||||
|
tenants.filter(isOrgFrontTenantType),
|
||||||
|
"internal",
|
||||||
|
);
|
||||||
const usersBySlug = new Map<string, UserSummary[]>();
|
const usersBySlug = new Map<string, UserSummary[]>();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (user.status !== "active") continue;
|
if (user.status !== "active") continue;
|
||||||
@@ -91,7 +134,8 @@ export function buildOrgPickerTree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const companyGroup =
|
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.type === "COMPANY_GROUP") ??
|
||||||
visibleTenants.find((tenant) => !tenant.parentId);
|
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")),
|
select: parseOrgPickerSelectableType(params.get("select")),
|
||||||
includeDescendants: params.get("includeDescendants") !== "false",
|
includeDescendants: params.get("includeDescendants") !== "false",
|
||||||
showDescendantToggle: params.get("showDescendantToggle") !== "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),
|
width: parseEmbedDimension(params.get("width"), 400),
|
||||||
height: parseEmbedDimension(params.get("height"), 600),
|
height: parseEmbedDimension(params.get("height"), 600),
|
||||||
};
|
};
|
||||||
@@ -84,9 +88,9 @@ export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
|
|||||||
height: String(options.height),
|
height: String(options.height),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tenantId = options.tenantId.trim();
|
const tenantSlug = options.tenantId.trim();
|
||||||
if (tenantId) {
|
if (tenantSlug) {
|
||||||
params.set("tenantId", tenantId);
|
params.set("tenantSlug", tenantSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.mode === "multiple") {
|
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 { GitBranch, Network, PanelTop } from "lucide-react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/chart", label: "조직도", icon: Network },
|
{ to: "/chart", label: "조직도", icon: Network },
|
||||||
@@ -8,9 +8,22 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function OrgFrontLayout() {
|
export function OrgFrontLayout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isChartRoute =
|
||||||
|
location.pathname === "/chart" || location.pathname.startsWith("/chart/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div
|
||||||
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
|
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 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>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
@@ -40,7 +53,14 @@ export function OrgFrontLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function PickerScenarioControls({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="space-y-1 text-sm font-medium">
|
<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
|
<input
|
||||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -68,7 +68,7 @@ function PickerScenarioControls({
|
|||||||
tenantId: event.target.value,
|
tenantId: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="company-baron"
|
placeholder="saman"
|
||||||
type="text"
|
type="text"
|
||||||
value={options.tenantId}
|
value={options.tenantId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export function OrgPickerEmbedPage() {
|
|||||||
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||||
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||||
const tenantId =
|
const tenantId =
|
||||||
|
searchParams.get("tenantSlug") ||
|
||||||
searchParams.get("tenantId") ||
|
searchParams.get("tenantId") ||
|
||||||
searchParams.get("companyTenantId") ||
|
searchParams.get("companyTenantId") ||
|
||||||
undefined;
|
undefined;
|
||||||
@@ -615,7 +616,7 @@ export function OrgPickerPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="space-y-1 text-sm font-medium">
|
<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
|
<input
|
||||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -624,7 +625,7 @@ export function OrgPickerPage() {
|
|||||||
tenantId: event.target.value,
|
tenantId: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="company-baron"
|
placeholder="saman"
|
||||||
type="text"
|
type="text"
|
||||||
value={options.tenantId}
|
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 = {
|
type UserAppointment = {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
|
grade?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
};
|
};
|
||||||
@@ -25,6 +26,7 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
tenantId: normalizeText(item.tenantId),
|
tenantId: normalizeText(item.tenantId),
|
||||||
tenantSlug: normalizeText(item.tenantSlug),
|
tenantSlug: normalizeText(item.tenantSlug),
|
||||||
|
grade: normalizeText(item.grade),
|
||||||
jobTitle: normalizeText(item.jobTitle),
|
jobTitle: normalizeText(item.jobTitle),
|
||||||
position: normalizeText(item.position),
|
position: normalizeText(item.position),
|
||||||
}));
|
}));
|
||||||
@@ -44,6 +46,7 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
grade: appointment?.grade || normalizeText(user.grade),
|
||||||
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||||
position: appointment?.position || normalizeText(user.position),
|
position: appointment?.position || normalizeText(user.position),
|
||||||
};
|
};
|
||||||
@@ -53,11 +56,12 @@ export function getOrgChartUserDisplayName(
|
|||||||
user: UserSummary,
|
user: UserSummary,
|
||||||
tenant?: TenantIdentity,
|
tenant?: TenantIdentity,
|
||||||
) {
|
) {
|
||||||
const { jobTitle, position } = getUserOrgProfile(user, tenant);
|
const { grade, jobTitle, position } = getUserOrgProfile(user, tenant);
|
||||||
const baseName = user.name.trim();
|
const baseName = user.name.trim();
|
||||||
|
const detail = position || jobTitle;
|
||||||
|
|
||||||
if (jobTitle && position) return `${baseName}(${jobTitle}) ${position}`;
|
if (grade && detail) return `${baseName} ${grade}(${detail})`;
|
||||||
if (jobTitle) return `${baseName}(${jobTitle})`;
|
if (grade) return `${baseName} ${grade}`;
|
||||||
if (position) return `${baseName} ${position}`;
|
if (detail) return `${baseName}(${detail})`;
|
||||||
return baseName;
|
return baseName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export type UserSummary = {
|
|||||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -410,6 +411,7 @@ export type UserCreateRequest = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
@@ -428,6 +430,7 @@ export type UserUpdateRequest = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
@@ -441,6 +444,7 @@ export type BulkUserItem = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
metadata: Record<string, string>;
|
metadata: Record<string, string>;
|
||||||
|
|||||||
@@ -1091,14 +1091,16 @@ email = "이메일"
|
|||||||
email_placeholder = "user@example.com"
|
email_placeholder = "user@example.com"
|
||||||
job_title = "직무"
|
job_title = "직무"
|
||||||
job_title_placeholder = "프론트엔드 개발"
|
job_title_placeholder = "프론트엔드 개발"
|
||||||
|
grade = "직급"
|
||||||
|
grade_placeholder = "수석/책임/선임"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
name_placeholder = "홍길동"
|
name_placeholder = "홍길동"
|
||||||
password = "비밀번호"
|
password = "비밀번호"
|
||||||
password_placeholder = "********"
|
password_placeholder = "********"
|
||||||
phone = "전화번호"
|
phone = "전화번호"
|
||||||
phone_placeholder = "010-1234-5678"
|
phone_placeholder = "010-1234-5678"
|
||||||
position = "직급"
|
position = "직책"
|
||||||
position_placeholder = "수석/책임/선임"
|
position_placeholder = "팀장/센터장"
|
||||||
role = "역할"
|
role = "역할"
|
||||||
tenant = "테넌트"
|
tenant = "테넌트"
|
||||||
tenant_global = "시스템 전역"
|
tenant_global = "시스템 전역"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type TenantFixture = {
|
|||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -41,7 +42,7 @@ function user(id: string, name: string, companyCode: string) {
|
|||||||
role: "user",
|
role: "user",
|
||||||
status: "active",
|
status: "active",
|
||||||
companyCode,
|
companyCode,
|
||||||
position: "사원",
|
grade: "사원",
|
||||||
createdAt: "2026-04-01T00:00:00.000Z",
|
createdAt: "2026-04-01T00:00:00.000Z",
|
||||||
updatedAt: "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);
|
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",
|
status: "active",
|
||||||
tenantSlug,
|
tenantSlug,
|
||||||
companyCode: tenantSlug,
|
companyCode: tenantSlug,
|
||||||
position: "사원",
|
grade: "사원",
|
||||||
createdAt: "2026-04-01T00:00:00.000Z",
|
createdAt: "2026-04-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -173,7 +173,8 @@ async function installOrgPickerApiMock(
|
|||||||
user("user-platform", "Platform User", "platform", {
|
user("user-platform", "Platform User", "platform", {
|
||||||
metadata: { employeeNumber: "EMP-9001", skill: "Kubernetes" },
|
metadata: { employeeNumber: "EMP-9001", skill: "Kubernetes" },
|
||||||
jobTitle: "Platform Engineer",
|
jobTitle: "Platform Engineer",
|
||||||
position: "책임",
|
grade: "책임",
|
||||||
|
position: "팀장",
|
||||||
}),
|
}),
|
||||||
user("user-sales", "Sales User", "sales"),
|
user("user-sales", "Sales User", "sales"),
|
||||||
];
|
];
|
||||||
@@ -252,14 +253,64 @@ test("picker menu lets developers switch selection mode and selectable type", as
|
|||||||
).toBeVisible();
|
).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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
|
await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", {
|
page.getByRole("button", {
|
||||||
name: "Platform User(Platform Engineer) 책임",
|
name: "Platform User 책임(팀장)",
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -319,17 +370,17 @@ test("embed preview menu updates the iframe picker source", async ({
|
|||||||
).toBeVisible();
|
).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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(withShareToken("/embed-preview"));
|
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("520");
|
||||||
await page.getByLabel("임베딩 높이").fill("480");
|
await page.getByLabel("임베딩 높이").fill("480");
|
||||||
|
|
||||||
await expect(page.getByTestId("embed-preview-src")).toContainText(
|
await expect(page.getByTestId("embed-preview-src")).toContainText(
|
||||||
"tenantId=company-baron",
|
"tenantSlug=baron",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("embed-preview-src")).toContainText(
|
await expect(page.getByTestId("embed-preview-src")).toContainText(
|
||||||
"width=520",
|
"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);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(
|
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(
|
await expect(page.getByTestId("embed-preview-src")).toContainText(
|
||||||
"tenantId=company-baron",
|
"tenantSlug=baron",
|
||||||
);
|
);
|
||||||
|
|
||||||
const picker = page.frameLocator("iframe");
|
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 picker.getByLabel("Engineering 선택").check();
|
||||||
await expect(picker.getByLabel("Platform 선택")).toBeChecked();
|
await expect(picker.getByLabel("Platform 선택")).toBeChecked();
|
||||||
await expect(
|
await expect(
|
||||||
picker.getByLabel("Platform User(Platform Engineer) 책임 선택"),
|
picker.getByLabel("Platform User 책임(팀장) 선택"),
|
||||||
).toBeChecked();
|
).toBeChecked();
|
||||||
await picker.getByRole("button", { name: "선택 완료" }).click();
|
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 picker.getByLabel("Engineering 선택").check();
|
||||||
await expect(picker.getByLabel("Platform 선택")).not.toBeChecked();
|
await expect(picker.getByLabel("Platform 선택")).not.toBeChecked();
|
||||||
await expect(
|
await expect(
|
||||||
picker.getByLabel("Platform User(Platform Engineer) 책임 선택"),
|
picker.getByLabel("Platform User 책임(팀장) 선택"),
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
await picker.getByRole("button", { name: "선택 완료" }).click();
|
await picker.getByRole("button", { name: "선택 완료" }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function user(id: string, name: string, companyCode: string) {
|
|||||||
role: "user",
|
role: "user",
|
||||||
status: "active",
|
status: "active",
|
||||||
companyCode,
|
companyCode,
|
||||||
position: "사원",
|
grade: "사원",
|
||||||
createdAt: "2026-04-01T00:00:00.000Z",
|
createdAt: "2026-04-01T00:00:00.000Z",
|
||||||
updatedAt: "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 viewport = page.locator('[data-testid="orgchart-viewport"]');
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(svg).toBeVisible();
|
await expect(svg).toBeVisible();
|
||||||
await expect(
|
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
|
||||||
svg.locator("text", { hasText: "Engineering User" }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
const initialViewBox = await svg.getAttribute("viewBox");
|
const initialViewBox = await svg.getAttribute("viewBox");
|
||||||
const transform = await page
|
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();
|
await expect(page.getByText("총 4명")).toBeVisible();
|
||||||
|
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(
|
await expect(svg.getByText(/Hidden Hanmac User/)).toHaveCount(0);
|
||||||
svg.locator("text", { hasText: "Hidden Hanmac User" }),
|
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
|
||||||
).toHaveCount(0);
|
await expect(svg.getByText("Sales User 사원")).toBeVisible();
|
||||||
await expect(
|
|
||||||
svg.locator("text", { hasText: "Engineering User" }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(svg.locator("text", { hasText: "Sales User" })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Baron" }).click();
|
await page.getByRole("button", { name: "Baron" }).click();
|
||||||
await expect(page.getByText("총 2명")).toBeVisible();
|
await expect(page.getByText("총 2명")).toBeVisible();
|
||||||
await expect(page.getByText("총 4명")).toHaveCount(0);
|
await expect(page.getByText("총 4명")).toHaveCount(0);
|
||||||
await expect(
|
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
|
||||||
svg.locator("text", { hasText: "Engineering User" }),
|
await expect(svg.getByText(/Sales User/)).toHaveCount(0);
|
||||||
).toBeVisible();
|
|
||||||
await expect(svg.locator("text", { hasText: "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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
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"),
|
...user("u-eng", "Engineering User", "engineering"),
|
||||||
jobTitle: "Platform Engineer",
|
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");
|
await page.goto("/chart?token=display-name");
|
||||||
|
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(
|
await expect(svg.getByText("Engineering User 책임(팀장)")).toBeVisible();
|
||||||
svg.locator("text", {
|
|
||||||
hasText: "Engineering User(Platform Engineer) 책임",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("org chart places multi-tenant users only on leaf memberships without duplicate rendering", async ({
|
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();
|
await expect(page.getByText("총 1명")).toBeVisible();
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(svg).toBeVisible();
|
await expect(svg).toBeVisible();
|
||||||
await expect(svg.locator("text", { hasText: "Shared User" })).toHaveCount(1);
|
await expect(svg.getByText(/Shared User/)).toHaveCount(1);
|
||||||
await expect(svg.locator("text").filter({ hasText: /^1$/ })).toHaveCount(4);
|
await expect(svg.getByText(/^1$/)).toHaveCount(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("org chart counts multi-leaf tenant users once in ancestor totals", async ({
|
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();
|
await expect(page.getByText("총 1명")).toBeVisible();
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(svg).toBeVisible();
|
await expect(svg).toBeVisible();
|
||||||
await expect(svg.locator("text", { hasText: "Shared User" })).toHaveCount(2);
|
await expect(svg.getByText(/Shared User/)).toHaveCount(2);
|
||||||
await expect(svg.locator("text").filter({ hasText: /^1$/ })).toHaveCount(5);
|
await expect(svg.getByText(/^1$/)).toHaveCount(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("org chart hides system global tenant members", async ({ page }) => {
|
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();
|
await expect(page.getByText("총 1명")).toBeVisible();
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(svg).toBeVisible();
|
await expect(svg).toBeVisible();
|
||||||
await expect(svg.locator("text", { hasText: "시스템 전역" })).toHaveCount(0);
|
await expect(svg.getByText(/시스템 전역/)).toHaveCount(0);
|
||||||
await expect(svg.locator("text", { hasText: "Global Admin" })).toHaveCount(0);
|
await expect(svg.getByText(/Global Admin/)).toHaveCount(0);
|
||||||
await expect(svg.locator("text", { hasText: "System Admin" })).toHaveCount(0);
|
await expect(svg.getByText(/System Admin/)).toHaveCount(0);
|
||||||
await expect(svg.locator("text", { hasText: "Baron User" })).toBeVisible();
|
await expect(svg.getByText("Baron User 사원")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user