forked from baron/baron-sso
네이버 웍스 연동기능 개선
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
|
||||
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"
|
||||
"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00"
|
||||
|
71
adminfront/baron-group_org.csv
Normal file
71
adminfront/baron-group_org.csv
Normal file
@@ -0,0 +1,71 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"(주)장헌","0","","","","jangheon@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","t_617rl@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
|
||||
"생산부","0","","","","t_921uz@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
|
||||
"공무팀","0","","","","t_733vx@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
|
||||
"철근팀","0","","","","t_334vk@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
|
||||
"제작1팀","0","","","","t_196vt@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
|
||||
"제작2팀","0","","","","t_690ka@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
|
||||
"품질팀","0","","","","t_013sr@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
|
||||
"업무지원팀","0","","","","t_601un@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
|
||||
"PTC","0","","","","ptc@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","t_771pf@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"영업팀","0","","","","t_375vv@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"사업관리팀","0","","","","t_054fx@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"시공팀","0","","","","t_871dc@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"설계팀","0","","","","t_156ss@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"네이버웍스관리용(바론그룹)","2","슈퍼관리자(su-@samaneng.com)","","","su4@brsw.kr","N","N","N","Y","","",""
|
||||
"장헌산업","0","","","","jangheon-sanup@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","t_049ij@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"경영지원부","0","","","","t_166wx@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"기술영업본부","0","","","","t_444be@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"영업","0","","","","t_999wg@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)"
|
||||
"기술지원","0","","","","t_512gs@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)"
|
||||
"견적","0","","","","t_917bs@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)"
|
||||
"건설본부","0","","","","t_054iq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"공무","0","","","","t_191nh@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)"
|
||||
"현장","0","","","","t_995wn@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)"
|
||||
"안전관리","0","","","","t_695sg@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)"
|
||||
"한라산업개발","0","","","","hanlla@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","t_080iz@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"업무총괄","0","","","","general-biz@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"영업총괄","0","","","","general_sales@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"경영지원본부","0","","","","t_261yp@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"업무팀","0","","","","t_407sk@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
|
||||
"사업지원팀","0","","","","t_265al@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
|
||||
"경영지원팀","0","","","","t_681nn@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
|
||||
"운영사업실","0","","","","t_174mm@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
|
||||
"기반사업본부","0","","","","t_785dc@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"사업관리팀","0","","","","t_422tk@brsw.kr","Y","N","Y","Y","","","기반사업본부(t_785dc@brsw.kr)"
|
||||
"환경플랜트사업본부","0","","","","t_558py@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"사업관리팀","0","","","","t_932wg@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(t_558py@brsw.kr)"
|
||||
"설계팀","0","","","","t_695kn@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(t_558py@brsw.kr)"
|
||||
"기술영업본부","0","","","","t_708iq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"기술영업팀","0","","","","t_026lk@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_708iq@brsw.kr)"
|
||||
"안전관리본부","0","","","","t_601wg@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"안전관리팀","0","","","","t_885ji@brsw.kr","Y","N","Y","Y","","","안전관리본부(t_601wg@brsw.kr)"
|
||||
"시공현장","0","","","","t_745dt@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"부천시 굴포천","0","","","","t_579tx@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"옥정 공공하수처리","0","","","","t_644eu@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"여주부평천","0","","","","t_923sy@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"도척 실촌간 도로","0","","","","t_583wq@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"광주공공폐수처리","0","","","","t_481kp@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"아포공공하수처리","0","","","","t_654ud@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"장량공공하수처리","0","","","","t_007gm@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"신천공공하수처리","0","","","","t_328ki@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"온산하수처리","0","","","","t_742au@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"수도권매립지 제2매립장","0","","","","t_850qe@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"인천국제공항 화물","0","","","","t_246jb@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"광탄공공하수처리","0","","","","t_256he@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"성남시생활폐기물처리","0","","","","t_148dm@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"제주공공하수처리","0","","","","t_317lj@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"인덕원 동탄 복선전철 제3공구","0","","","","t_227xx@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"인덕원 동탄 복선전철 제7공구","0","","","","t_605dg@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"경산시 국도대체","0","","","","t_020pv@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"수도권광역급행철도B 제4공구","0","","","","t_217mu@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"부산항 신항","0","","","","t_282jo@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
|
||||
"운영사업소","0","","","","t_993sp@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"울산민자소각","0","","","","t_600hp@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
|
||||
"온산바이오","0","","","","t_374ak@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
|
||||
"안성제4차산업단지폐수처리","0","","","","t_749lk@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
|
||||
"서산시자원회수시설","0","","","","t_056hs@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
|
||||
|
71
adminfront/baron-group_org_slugged.csv
Normal file
71
adminfront/baron-group_org_slugged.csv
Normal file
@@ -0,0 +1,71 @@
|
||||
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
|
||||
"(주)장헌","0","","","","jangheon@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","jangheon-executive@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
|
||||
"생산부","0","","","","jangheon-production@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
|
||||
"공무팀","0","","","","jangheon-production-admin@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
|
||||
"철근팀","0","","","","jangheon-rebar@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
|
||||
"제작1팀","0","","","","jangheon-fab-1@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
|
||||
"제작2팀","0","","","","jangheon-fab-2@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
|
||||
"품질팀","0","","","","jangheon-quality@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
|
||||
"업무지원팀","0","","","","jangheon-business-support@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
|
||||
"PTC","0","","","","ptc@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","ptc-executive@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"영업팀","0","","","","ptc-sales@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"사업관리팀","0","","","","ptc-project-management@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"시공팀","0","","","","ptc-construction@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"설계팀","0","","","","ptc-design@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
|
||||
"네이버웍스관리용(바론그룹)","2","슈퍼관리자(su-@samaneng.com)","","","nw-admin-baron-group@brsw.kr","N","N","N","Y","","",""
|
||||
"장헌산업","0","","","","jangheon-sanup@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","jangheon-sanup-executive@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"경영지원부","0","","","","js-mgmt-support@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"기술영업본부","0","","","","js-tech-sales-hq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"영업","0","","","","js-sales@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)"
|
||||
"기술지원","0","","","","js-tech-support@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)"
|
||||
"견적","0","","","","js-estimation@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)"
|
||||
"건설본부","0","","","","js-construction-hq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
|
||||
"공무","0","","","","js-construction-admin@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)"
|
||||
"현장","0","","","","js-site@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)"
|
||||
"안전관리","0","","","","js-safety-management@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)"
|
||||
"한라산업개발","0","","","","hanlla@brsw.kr","Y","N","Y","Y","","",""
|
||||
"임원실","0","","","","hanlla-executive@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"업무총괄","0","","","","hanlla-general-business@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"영업총괄","0","","","","hanlla-general-sales@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"경영지원본부","0","","","","hanlla-mgmt-support-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"업무팀","0","","","","hanlla-operations@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
|
||||
"사업지원팀","0","","","","hanlla-business-support@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
|
||||
"경영지원팀","0","","","","hanlla-mgmt-support@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
|
||||
"운영사업실","0","","","","hanlla-operations-office@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
|
||||
"기반사업본부","0","","","","hanlla-infra-business-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"사업관리팀","0","","","","hanlla-infra-project-mgmt@brsw.kr","Y","N","Y","Y","","","기반사업본부(hanlla-infra-business-hq@brsw.kr)"
|
||||
"환경플랜트사업본부","0","","","","hanlla-env-plant-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"사업관리팀","0","","","","hanlla-env-project-mgmt@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)"
|
||||
"설계팀","0","","","","hanlla-env-plant-design@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)"
|
||||
"기술영업본부","0","","","","hanlla-tech-sales-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"기술영업팀","0","","","","hanlla-tech-sales-team@brsw.kr","Y","N","Y","Y","","","기술영업본부(hanlla-tech-sales-hq@brsw.kr)"
|
||||
"안전관리본부","0","","","","hanlla-safety-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"안전관리팀","0","","","","hanlla-safety-team@brsw.kr","Y","N","Y","Y","","","안전관리본부(hanlla-safety-hq@brsw.kr)"
|
||||
"시공현장","0","","","","hanlla-construction-sites@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"부천시 굴포천","0","","","","site-bucheon-gulpocheon@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"옥정 공공하수처리","0","","","","site-okjeong-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"여주부평천","0","","","","site-yeoju-bupyeongcheon@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"도척 실촌간 도로","0","","","","site-docheok-silchon-road@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"광주공공폐수처리","0","","","","site-gwangju-wastewater@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"아포공공하수처리","0","","","","site-apo-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"장량공공하수처리","0","","","","site-jangnyang-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"신천공공하수처리","0","","","","site-sincheon-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"온산하수처리","0","","","","site-onsan-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"수도권매립지 제2매립장","0","","","","site-sudokwon-landfill-2@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"인천국제공항 화물","0","","","","site-incheon-air-cargo@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"광탄공공하수처리","0","","","","site-gwangtan-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"성남시생활폐기물처리","0","","","","site-seongnam-waste@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"제주공공하수처리","0","","","","site-jeju-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"인덕원 동탄 복선전철 제3공구","0","","","","site-indeokwon-dongtan-3@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"인덕원 동탄 복선전철 제7공구","0","","","","site-indeokwon-dongtan-7@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"경산시 국도대체","0","","","","site-gyeongsan-road@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"수도권광역급행철도B 제4공구","0","","","","site-gtx-b-4@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"부산항 신항","0","","","","site-busan-new-port@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
|
||||
"운영사업소","0","","","","hanlla-operation-sites@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
|
||||
"울산민자소각","0","","","","ops-ulsan-incineration@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
|
||||
"온산바이오","0","","","","ops-onsan-bio@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
|
||||
"안성제4차산업단지폐수처리","0","","","","ops-anseong-wwtp@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
|
||||
"서산시자원회수시설","0","","","","ops-seosan-recovery@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
|
||||
|
@@ -14,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -56,7 +57,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: chromiumExecutablePath
|
||||
? { executablePath: chromiumExecutablePath }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
# If common workspace exists, manage dependencies from there
|
||||
if [ -d /common ] && [ -f /common/package.json ]; then
|
||||
WORKSPACE_DIR="/common"
|
||||
LOCK_FILE="/common/pnpm-lock.yaml"
|
||||
APP_WORKSPACE_FILTER="../adminfront"
|
||||
|
||||
# If common workspace exists, manage dependencies from the real workspace tree.
|
||||
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
|
||||
WORKSPACE_DIR="/workspace/common"
|
||||
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
if [ "$WORKSPACE_DIR" = "/common" ]; then
|
||||
|
||||
(cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog FocusScope integration", () => {
|
||||
it("mounts an open dialog without a ref update loop", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Focus scope check</DialogTitle>
|
||||
<DialogDescription>Dialog content is mounted.</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Focus scope check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,221 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
type DialogContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
function useDialogContext(componentName: string) {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error(`${componentName} must be used within Dialog`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
function composeEventHandlers<E extends React.SyntheticEvent>(
|
||||
theirs: ((event: E) => void) | undefined,
|
||||
ours: (event: E) => void,
|
||||
) {
|
||||
return (event: E) => {
|
||||
theirs?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
ours(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type DialogProps = {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Dialog({
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: DialogProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const currentOpen = isControlled ? open : internalOpen;
|
||||
const setOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ open: currentOpen, setOpen }),
|
||||
[currentOpen, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogTrigger");
|
||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(true),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleOpen} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
DialogTriggerProps
|
||||
>(({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogClose");
|
||||
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(false),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleClose} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-state="open"
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setOpen(false);
|
||||
}
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, onKeyDown, ...props }, ref) => {
|
||||
const { open, setOpen } = useDialogContext("DialogContent");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
return () => document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-state="open"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
@@ -80,10 +246,10 @@ const DialogFooter = ({
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
@@ -92,19 +258,19 @@ const DialogTitle = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
interface SwitchProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isControlled = checked !== undefined;
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const currentChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return;
|
||||
}
|
||||
const nextChecked = !currentChecked;
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={currentChecked}
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -22,6 +22,17 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥그룹",
|
||||
slug: "hanmac-group",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
@@ -58,7 +69,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 3,
|
||||
total: 4,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
@@ -150,7 +161,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
@@ -168,7 +179,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("3");
|
||||
).toHaveTextContent("4");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
@@ -180,17 +191,30 @@ describe("admin overview and auth guard pages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted organization", async () => {
|
||||
it("limits the overview graph choices to company tenants", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted company", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
@@ -198,6 +222,10 @@ describe("admin overview and auth guard pages", () => {
|
||||
period: "month",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
|
||||
});
|
||||
@@ -217,7 +245,7 @@ describe("admin overview and auth guard pages", () => {
|
||||
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type DataIntegrityStatus,
|
||||
@@ -21,11 +26,6 @@ import {
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
@@ -72,7 +72,10 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects);
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
);
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
@@ -200,10 +203,7 @@ function IntegrityOverviewSummary() {
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.title",
|
||||
"정합성 최종 검증",
|
||||
)}
|
||||
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
@@ -213,11 +213,9 @@ function IntegrityOverviewSummary() {
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.failures_text",
|
||||
"실패 {{count}}건",
|
||||
{ count: data.summary.failures },
|
||||
)}
|
||||
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
|
||||
count: data.summary.failures,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
@@ -303,7 +301,7 @@ function RPUsageMixedChart({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -397,17 +395,20 @@ function RPUsageMixedChart({
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<span className="font-medium">{item.clientLabel}</span>
|
||||
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{t(
|
||||
@@ -423,7 +424,6 @@ function RPUsageMixedChart({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -444,7 +444,7 @@ function GlobalOverviewPage() {
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
(tenant) => tenant.type === "COMPANY",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
@@ -582,7 +582,7 @@ function GlobalOverviewPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { filterParentTenants } from "./ParentTenantSelector";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
type ParentTenantSelectorProps = {
|
||||
id: string;
|
||||
@@ -33,26 +34,6 @@ type ParentTenantSelectorProps = {
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage";
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
||||
|
||||
@@ -6,14 +6,7 @@ import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
parentId,
|
||||
type: parentId ? "ORGANIZATION" : "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tenants = [
|
||||
tenant("company-1", "한맥기술", "hanmac"),
|
||||
tenant("dept-1", "기술기획", "planning", "company-1"),
|
||||
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
|
||||
tenant("company-2", "삼안", "saman"),
|
||||
];
|
||||
|
||||
describe("TenantListPage tenant list helpers", () => {
|
||||
it("selects a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: [],
|
||||
tenant: tenants[0],
|
||||
checked: true,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-1", "dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("removes a parent tenant together with every descendant", () => {
|
||||
expect(
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
|
||||
tenant: tenants[0],
|
||||
checked: false,
|
||||
tenants,
|
||||
deletableTenants: tenants,
|
||||
}),
|
||||
).toEqual(["company-2"]);
|
||||
});
|
||||
|
||||
it("filters to descendants of the selected scope tenant", () => {
|
||||
expect(
|
||||
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
|
||||
).toEqual(["dept-1", "team-1"]);
|
||||
});
|
||||
|
||||
it("searches tenants by name, slug, and UUID", () => {
|
||||
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
|
||||
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
|
||||
});
|
||||
|
||||
it("can return tree rows or same-level table rows", () => {
|
||||
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
|
||||
0, 1, 2, 0,
|
||||
]);
|
||||
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -104,8 +104,10 @@ import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
isHanmacFamilyUser,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
@@ -117,6 +119,14 @@ import {
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
import {
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
||||
@@ -266,6 +276,9 @@ function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
||||
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
||||
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<TenantSortKey> | null>({
|
||||
key: "createdAt",
|
||||
@@ -470,8 +483,14 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantPages = query.data?.pages ?? [];
|
||||
const rawTenants = tenantPages.flatMap((page) => page.items);
|
||||
const tenantPages = React.useMemo(
|
||||
() => query.data?.pages ?? [],
|
||||
[query.data?.pages],
|
||||
);
|
||||
const rawTenants = React.useMemo(
|
||||
() => tenantPages.flatMap((page) => page.items),
|
||||
[tenantPages],
|
||||
);
|
||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
@@ -492,6 +511,18 @@ function TenantListPage() {
|
||||
}
|
||||
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
|
||||
}, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
|
||||
const scopedTenants = React.useMemo(
|
||||
() => filterTenantsByScope(allTenants, scopeTenantId),
|
||||
[allTenants, scopeTenantId],
|
||||
);
|
||||
const selectedScopeTenant = React.useMemo(
|
||||
() => allTenants.find((tenant) => tenant.id === scopeTenantId),
|
||||
[allTenants, scopeTenantId],
|
||||
);
|
||||
const scopePickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
hanmacFamilyTenantId ? { tenantId: hanmacFamilyTenantId } : {},
|
||||
);
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
|
||||
@@ -511,10 +542,37 @@ function TenantListPage() {
|
||||
};
|
||||
|
||||
const deletableTenants = React.useMemo(
|
||||
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
[allTenants],
|
||||
() => scopedTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
[scopedTenants],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const selectableIds = new Set(deletableTenants.map((tenant) => tenant.id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => selectableIds.has(id));
|
||||
if (next.length === prev.length) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [deletableTenants]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scopePickerOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
if (!allTenants.some((tenant) => tenant.id === selection.id)) return;
|
||||
|
||||
setScopeTenantId(selection.id);
|
||||
setScopePickerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [allTenants, scopePickerOpen]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(deletableTenants.map((t) => t.id));
|
||||
@@ -527,11 +585,15 @@ function TenantListPage() {
|
||||
if (isSeedTenant(tenant)) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => [...prev, tenant.id]);
|
||||
} else {
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
|
||||
}
|
||||
setSelectedIds((prev) =>
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: prev,
|
||||
tenant,
|
||||
checked,
|
||||
tenants: allTenants,
|
||||
deletableTenants,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteBulk = () => {
|
||||
@@ -701,13 +763,67 @@ function TenantListPage() {
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
"테넌트 이름, 슬러그, UUID 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
@@ -818,7 +934,7 @@ function TenantListPage() {
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개 테넌트",
|
||||
{
|
||||
count: tenantTotal,
|
||||
count: scopeTenantId ? scopedTenants.length : tenantTotal,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
@@ -846,10 +962,34 @@ function TenantListPage() {
|
||||
sortConfig={sortConfig}
|
||||
requestSort={requestSort}
|
||||
getSortIcon={getSortIcon}
|
||||
viewMode={viewMode}
|
||||
scopeTenantId={scopeTenantId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={scopePickerOpen} onOpenChange={setScopePickerOpen}>
|
||||
<DialogContent className="max-w-[480px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.scope.description",
|
||||
"orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
src={scopePickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
data-testid="tenant-scope-picker-frame"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div
|
||||
@@ -1212,6 +1352,8 @@ const TenantHierarchyView: React.FC<{
|
||||
sortConfig: SortConfig<TenantSortKey> | null;
|
||||
requestSort: (key: TenantSortKey) => void;
|
||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||
viewMode: TenantViewMode;
|
||||
scopeTenantId: string;
|
||||
}> = ({
|
||||
tenants,
|
||||
selectedIds,
|
||||
@@ -1226,10 +1368,12 @@ const TenantHierarchyView: React.FC<{
|
||||
sortConfig,
|
||||
requestSort,
|
||||
getSortIcon,
|
||||
viewMode,
|
||||
scopeTenantId,
|
||||
}) => {
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants),
|
||||
[tenants],
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
@@ -1245,6 +1389,18 @@ const TenantHierarchyView: React.FC<{
|
||||
return ids;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const ids = new Set<string>();
|
||||
const collect = (nodes: TenantNode[]) => {
|
||||
for (const n of nodes) {
|
||||
ids.add(n.id);
|
||||
if (n.children) collect(n.children);
|
||||
}
|
||||
};
|
||||
collect(subTree);
|
||||
setExpandedIds((prev) => new Set([...prev, ...ids]));
|
||||
}, [subTree]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1267,7 +1423,17 @@ const TenantHierarchyView: React.FC<{
|
||||
);
|
||||
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
const result: (TenantNode & { depth: number })[] = [];
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
|
||||
tenantMatchesListSearch(tenant, search),
|
||||
),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
}
|
||||
|
||||
const result: TenantViewRow[] = [];
|
||||
const term = search.toLowerCase().trim();
|
||||
|
||||
// When searching, we show matched nodes and all their ancestors.
|
||||
@@ -1275,10 +1441,7 @@ const TenantHierarchyView: React.FC<{
|
||||
if (term) {
|
||||
const findMatches = (nodes: TenantNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.name.toLowerCase().includes(term) ||
|
||||
node.slug.toLowerCase().includes(term)
|
||||
) {
|
||||
if (tenantMatchesListSearch(node, term)) {
|
||||
matchedIds.add(node.id);
|
||||
}
|
||||
if (node.children) findMatches(node.children);
|
||||
@@ -1312,7 +1475,24 @@ const TenantHierarchyView: React.FC<{
|
||||
};
|
||||
collect(subTree, 0);
|
||||
return result;
|
||||
}, [subTree, expandedIds, search, sortConfig, tenantSortResolvers]);
|
||||
}, [
|
||||
expandedIds,
|
||||
scopeTenantId,
|
||||
search,
|
||||
sortConfig,
|
||||
subTree,
|
||||
tenantSortResolvers,
|
||||
tenants,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const visibleSelectableIds = React.useMemo(
|
||||
() => new Set(deletableTenants.map((tenant) => tenant.id)),
|
||||
[deletableTenants],
|
||||
);
|
||||
const visibleSelectedCount = selectedIds.filter((id) =>
|
||||
visibleSelectableIds.has(id),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
||||
@@ -1324,7 +1504,7 @@ const TenantHierarchyView: React.FC<{
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
selectedIds.length === deletableTenants.length
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
@@ -1409,8 +1589,12 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableRow>
|
||||
)}
|
||||
{flattenedRows.map((node) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedIds.has(node.id) || !!search;
|
||||
const hasChildren =
|
||||
viewMode === "tree" &&
|
||||
node.children &&
|
||||
node.children.length > 0;
|
||||
const isExpanded =
|
||||
viewMode === "tree" && (expandedIds.has(node.id) || !!search);
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
|
||||
@@ -17,81 +17,12 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
import {
|
||||
type SchemaField,
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
normalizeSchemaField,
|
||||
} from "./tenantSchemaFields";
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
|
||||
@@ -4,17 +4,21 @@ import {
|
||||
canCreateWorksmobileRow,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canSelectWorksmobileRow,
|
||||
filterVisibleWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRows,
|
||||
filterWorksmobileComparisonRowsBySearch,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} from "./TenantWorksmobilePage";
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
it("summarizes comparison rows by status", () => {
|
||||
@@ -143,6 +147,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides protected WORKS member accounts from comparison lists", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-su",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronEmail: "CYHAN1@HANMACENG.CO.KR",
|
||||
baronId: "baron-cyhan1",
|
||||
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
|
||||
worksmobileId: "works-cyhan1",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "normal@samaneng.com",
|
||||
worksmobileId: "works-normal",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
worksmobileId: "works-group",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
|
||||
rows[2],
|
||||
rows[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps row selection keys separate from Baron action ids", () => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -231,7 +271,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
|
||||
).toEqual([rows[0], rows[1], rows[3]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, [
|
||||
"baron_only",
|
||||
@@ -239,6 +280,147 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
"matched",
|
||||
]),
|
||||
).toEqual(rows);
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(
|
||||
rows,
|
||||
["baron_only", "works_only", "matched"],
|
||||
true,
|
||||
),
|
||||
).toEqual([rows[0], rows[2], rows[3]]);
|
||||
});
|
||||
|
||||
it("narrows works-only rows to missing external key rows from the detail filter", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-only",
|
||||
baronName: "Baron only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
worksmobileName: "WORKS only",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "missing-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, ["works_only"], false),
|
||||
).toEqual([rows[1], rows[2]]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
|
||||
[rows[2]],
|
||||
);
|
||||
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
|
||||
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
|
||||
[rows[0]],
|
||||
);
|
||||
});
|
||||
|
||||
it("filters comparison rows by names and identifiers in real time", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "matched",
|
||||
baronId: "baron-user-uuid",
|
||||
baronName: "홍길동",
|
||||
worksmobileName: "Hong Gildong",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-org-uuid",
|
||||
worksmobileName: "기술연구소",
|
||||
worksmobileParentName: "한맥가족",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_worksmobile",
|
||||
baronId: "baron-org-uuid",
|
||||
baronSlug: "baron-group-design",
|
||||
baronName: "디자인팀",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
|
||||
rows[0],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
|
||||
rows[1],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
|
||||
rows[2],
|
||||
]);
|
||||
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-user-missing-key",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
|
||||
getWorksmobileRowSelectionKey(rows[0]),
|
||||
getWorksmobileRowSelectionKey(rows[1]),
|
||||
getWorksmobileRowSelectionKey(rows[2]),
|
||||
]),
|
||||
).toEqual(["works-missing-key"]);
|
||||
});
|
||||
|
||||
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
|
||||
const rows = [
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_external_key",
|
||||
worksmobileId: "works-missing-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "missing_in_baron",
|
||||
worksmobileId: "works-only",
|
||||
externalKey: "legacy-external-key",
|
||||
},
|
||||
{
|
||||
resourceType: "GROUP",
|
||||
status: "matched",
|
||||
baronId: "baron-matched",
|
||||
worksmobileId: "works-matched",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows,
|
||||
rows.map(getWorksmobileRowSelectionKey),
|
||||
),
|
||||
).toEqual(["works-missing-key", "works-only"]);
|
||||
});
|
||||
|
||||
it("orders user comparison filter options from Baron-only first", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
126
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
126
adminfront/src/features/tenants/routes/tenantListView.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
|
||||
export type TenantViewMode = "tree" | "table";
|
||||
export type TenantViewRow = TenantNode & { depth: number };
|
||||
|
||||
export function tenantMatchesListSearch(
|
||||
tenant: Pick<TenantSummary, "id" | "name" | "slug" | "type">,
|
||||
search: string,
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.id, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
function collectTenantTreeRows(
|
||||
nodes: TenantNode[],
|
||||
depth: number,
|
||||
rows: TenantViewRow[],
|
||||
) {
|
||||
for (const node of nodes) {
|
||||
rows.push({ ...node, depth });
|
||||
collectTenantTreeRows(node.children, depth + 1, rows);
|
||||
}
|
||||
}
|
||||
|
||||
function collectTenantDescendantIds(
|
||||
tenantId: string,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const childrenByParent = new Map<string, TenantSummary[]>();
|
||||
for (const tenant of tenants) {
|
||||
if (!tenant.parentId) continue;
|
||||
const children = childrenByParent.get(tenant.parentId) ?? [];
|
||||
children.push(tenant);
|
||||
childrenByParent.set(tenant.parentId, children);
|
||||
}
|
||||
|
||||
const ids: string[] = [];
|
||||
const visitedIds = new Set<string>();
|
||||
const visit = (parentId: string) => {
|
||||
for (const child of childrenByParent.get(parentId) ?? []) {
|
||||
if (visitedIds.has(child.id)) continue;
|
||||
visitedIds.add(child.id);
|
||||
ids.push(child.id);
|
||||
visit(child.id);
|
||||
}
|
||||
};
|
||||
visit(tenantId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function filterTenantsByScope(
|
||||
tenants: TenantSummary[],
|
||||
scopeTenantId: string,
|
||||
) {
|
||||
if (!scopeTenantId) return tenants;
|
||||
const descendantIds = new Set(
|
||||
collectTenantDescendantIds(scopeTenantId, tenants),
|
||||
);
|
||||
return tenants.filter((tenant) => descendantIds.has(tenant.id));
|
||||
}
|
||||
|
||||
export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
if (viewMode === "tree") {
|
||||
return treeRows;
|
||||
}
|
||||
|
||||
const rowsById = new Map(treeRows.map((row) => [row.id, row]));
|
||||
const flatSource = scopeTenantId
|
||||
? filterTenantsByScope(tenants, scopeTenantId)
|
||||
: tenants;
|
||||
|
||||
return flatSource.map((tenant) => ({
|
||||
...(rowsById.get(tenant.id) ?? {
|
||||
...tenant,
|
||||
children: [],
|
||||
recursiveMemberCount: Number(tenant.memberCount) || 0,
|
||||
}),
|
||||
depth: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveTenantSelectionIds({
|
||||
currentIds,
|
||||
tenant,
|
||||
checked,
|
||||
tenants,
|
||||
deletableTenants,
|
||||
}: {
|
||||
currentIds: string[];
|
||||
tenant: TenantSummary;
|
||||
checked: boolean;
|
||||
tenants: TenantSummary[];
|
||||
deletableTenants: TenantSummary[];
|
||||
}) {
|
||||
const allowedIds = new Set(deletableTenants.map((item) => item.id));
|
||||
const targetIds = [
|
||||
tenant.id,
|
||||
...collectTenantDescendantIds(tenant.id, tenants),
|
||||
].filter((id) => allowedIds.has(id));
|
||||
const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
|
||||
|
||||
if (checked) {
|
||||
for (const id of targetIds) {
|
||||
next.add(id);
|
||||
}
|
||||
} else {
|
||||
for (const id of targetIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(next);
|
||||
}
|
||||
74
adminfront/src/features/tenants/routes/tenantSchemaFields.ts
Normal file
74
adminfront/src/features/tenants/routes/tenantSchemaFields.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
359
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
359
adminfront/src/features/tenants/routes/worksmobileComparison.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
|
||||
|
||||
export type WorksmobileComparisonFilter =
|
||||
| "works_only"
|
||||
| "baron_only"
|
||||
| "matched";
|
||||
|
||||
export type WorksmobileComparisonSummary = {
|
||||
total: number;
|
||||
matched: number;
|
||||
missingInWorksmobile: number;
|
||||
missingInBaron: number;
|
||||
missingExternalKey: number;
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonColumnKey =
|
||||
| "status"
|
||||
| "baronId"
|
||||
| "baron"
|
||||
| "baronOrg"
|
||||
| "worksmobileId"
|
||||
| "externalKey"
|
||||
| "worksmobileDomain"
|
||||
| "worksmobile"
|
||||
| "worksmobileOrg"
|
||||
| "manage";
|
||||
|
||||
export type WorksmobileComparisonColumnVisibility = Record<
|
||||
WorksmobileComparisonColumnKey,
|
||||
boolean
|
||||
>;
|
||||
|
||||
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
|
||||
return {
|
||||
status: true,
|
||||
baronId: false,
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
manage: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeWorksmobileComparison(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
): WorksmobileComparisonSummary {
|
||||
return rows.reduce<WorksmobileComparisonSummary>(
|
||||
(summary, row) => {
|
||||
if (row.status === "matched") {
|
||||
summary.matched += 1;
|
||||
} else if (row.status === "missing_in_worksmobile") {
|
||||
summary.missingInWorksmobile += 1;
|
||||
} else if (row.status === "missing_in_baron") {
|
||||
summary.missingInBaron += 1;
|
||||
} else if (row.status === "missing_external_key") {
|
||||
summary.missingExternalKey += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
total: rows.length,
|
||||
matched: 0,
|
||||
missingInWorksmobile: 0,
|
||||
missingInBaron: 0,
|
||||
missingExternalKey: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileComparisonStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "matched":
|
||||
return "일치";
|
||||
case "missing_in_worksmobile":
|
||||
return "WORKS 없음";
|
||||
case "missing_in_baron":
|
||||
return "Baron 없음";
|
||||
case "missing_external_key":
|
||||
return "ex_key 없음";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
|
||||
}
|
||||
|
||||
const immutableWorksmobileAccountEmails = new Set([
|
||||
"cyhan@samaneng.com",
|
||||
"cyhan1@hanmaceng.co.kr",
|
||||
"cyhan2@baroncs.co.kr",
|
||||
"cyhan3@brsw.kr",
|
||||
"su-@samaneng.com",
|
||||
]);
|
||||
|
||||
const hiddenWorksmobileMemberEmails = new Set([
|
||||
"su-@samaneng.com",
|
||||
"cyhan1@hanmaceng.co.kr",
|
||||
"cyhan2@baroncs.co.kr",
|
||||
"cyhan3@brsw.kr",
|
||||
]);
|
||||
|
||||
function normalizeWorksmobileEmail(email?: string) {
|
||||
return email?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
|
||||
return (
|
||||
row.resourceType === "USER" &&
|
||||
immutableWorksmobileAccountEmails.has(
|
||||
normalizeWorksmobileEmail(row.worksmobileEmail),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
|
||||
if (row.resourceType !== "USER") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [row.worksmobileEmail, row.baronEmail].some((email) =>
|
||||
hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterVisibleWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
) {
|
||||
return rows.filter((row) => !isHiddenWorksmobileMember(row));
|
||||
}
|
||||
|
||||
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
|
||||
if (row.baronId) {
|
||||
return `${row.resourceType}:baron:${row.baronId}`;
|
||||
}
|
||||
if (row.worksmobileId) {
|
||||
return `${row.resourceType}:works:${row.worksmobileId}`;
|
||||
}
|
||||
if (row.externalKey) {
|
||||
return `${row.resourceType}:external:${row.externalKey}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
return (
|
||||
Boolean(getWorksmobileRowSelectionKey(row)) &&
|
||||
!isImmutableWorksmobileAccount(row)
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedActionIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
|
||||
.map((row) => row.baronId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
|
||||
(id) =>
|
||||
rows.some(
|
||||
(row) =>
|
||||
row.worksmobileId === id && row.status === "missing_external_key",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "GROUP" &&
|
||||
(row.status === "missing_external_key" ||
|
||||
row.status === "missing_in_baron") &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.worksmobileId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
const worksmobileComparisonSearchFields: Array<
|
||||
keyof WorksmobileComparisonItem
|
||||
> = [
|
||||
"baronId",
|
||||
"baronSlug",
|
||||
"baronName",
|
||||
"baronEmail",
|
||||
"baronPrimaryOrgId",
|
||||
"baronPrimaryOrgSlug",
|
||||
"baronPrimaryOrgName",
|
||||
"baronParentId",
|
||||
"baronParentSlug",
|
||||
"baronParentName",
|
||||
"worksmobileId",
|
||||
"externalKey",
|
||||
"worksmobileName",
|
||||
"worksmobileEmail",
|
||||
"worksmobileLevelId",
|
||||
"worksmobileLevelName",
|
||||
"worksmobileTask",
|
||||
"worksmobileDomainId",
|
||||
"worksmobileDomainName",
|
||||
"worksmobilePrimaryOrgId",
|
||||
"worksmobilePrimaryOrgName",
|
||||
"worksmobilePrimaryOrgPositionId",
|
||||
"worksmobilePrimaryOrgPositionName",
|
||||
"baronParentWorksmobileId",
|
||||
"baronParentWorksmobileName",
|
||||
"baronParentWorksmobileEmail",
|
||||
"worksmobileParentId",
|
||||
"worksmobileParentName",
|
||||
"worksmobileParentEmail",
|
||||
"worksmobileParentExternalKey",
|
||||
];
|
||||
|
||||
export function filterWorksmobileComparisonRowsBySearch(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
search: string,
|
||||
) {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
worksmobileComparisonSearchFields.some((field) => {
|
||||
const value = row[field];
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
return String(value).toLowerCase().includes(keyword);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterWorksmobileComparisonRows(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
filters: WorksmobileComparisonFilter[],
|
||||
onlyMissingExternalKey = false,
|
||||
) {
|
||||
const allowedStatuses = new Set(
|
||||
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
|
||||
);
|
||||
if (filters.includes("works_only")) {
|
||||
if (onlyMissingExternalKey) {
|
||||
allowedStatuses.delete("missing_in_baron");
|
||||
}
|
||||
allowedStatuses.add("missing_external_key");
|
||||
}
|
||||
return rows.filter((row) => allowedStatuses.has(row.status));
|
||||
}
|
||||
|
||||
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
|
||||
return [
|
||||
row.worksmobileName,
|
||||
row.worksmobileLevelName ?? row.worksmobileLevelId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
const details: string[] = [];
|
||||
const position =
|
||||
row.worksmobilePrimaryOrgPositionName ??
|
||||
row.worksmobilePrimaryOrgPositionId;
|
||||
if (position) {
|
||||
details.push(`직책 ${position}`);
|
||||
}
|
||||
if (row.worksmobileTask) {
|
||||
details.push(`직무 ${row.worksmobileTask}`);
|
||||
}
|
||||
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
|
||||
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId,
|
||||
userIdNo,
|
||||
}: {
|
||||
tenantId?: string;
|
||||
domainId?: number;
|
||||
userIdNo?: string;
|
||||
}) {
|
||||
const normalizedTenantId = tenantId?.trim();
|
||||
const normalizedUserIdNo = userIdNo?.trim();
|
||||
if (
|
||||
!normalizedTenantId ||
|
||||
!domainId ||
|
||||
domainId <= 0 ||
|
||||
!normalizedUserIdNo
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
|
||||
url.searchParams.set("usage", "admin");
|
||||
url.searchParams.set("targetUserTenantId", normalizedTenantId);
|
||||
url.searchParams.set("targetUserDomainId", String(domainId));
|
||||
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
|
||||
url.searchParams.set(
|
||||
"accessUrl",
|
||||
"https://admin.worksmobile.com/assets/self-close.html",
|
||||
);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function canOpenWorksmobilePasswordManage(
|
||||
row: WorksmobileComparisonItem,
|
||||
tenantId?: string,
|
||||
) {
|
||||
return (
|
||||
row.resourceType === "USER" &&
|
||||
!isImmutableWorksmobileAccount(row) &&
|
||||
Boolean(
|
||||
buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId: row.worksmobileDomainId,
|
||||
userIdNo: row.worksmobileId,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const comparisonFilterOptions: Array<{
|
||||
value: WorksmobileComparisonFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "baron_only", label: "바론에만 있음" },
|
||||
{ value: "works_only", label: "웍스에만 있음" },
|
||||
{ value: "matched", label: "양쪽 다 있음" },
|
||||
];
|
||||
|
||||
export const userFilterOptions = comparisonFilterOptions;
|
||||
|
||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||
{
|
||||
baron_only: ["missing_in_worksmobile"],
|
||||
works_only: ["missing_in_baron"],
|
||||
matched: ["matched"],
|
||||
};
|
||||
@@ -789,11 +789,14 @@ export type WorksmobileOverview = {
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
baronSlug?: string;
|
||||
baronName?: string;
|
||||
baronEmail?: string;
|
||||
baronPrimaryOrgId?: string;
|
||||
baronPrimaryOrgSlug?: string;
|
||||
baronPrimaryOrgName?: string;
|
||||
baronParentId?: string;
|
||||
baronParentSlug?: string;
|
||||
baronParentName?: string;
|
||||
worksmobileId?: string;
|
||||
externalKey?: string;
|
||||
@@ -809,8 +812,13 @@ export type WorksmobileComparisonItem = {
|
||||
worksmobilePrimaryOrgPositionId?: string;
|
||||
worksmobilePrimaryOrgPositionName?: string;
|
||||
worksmobilePrimaryOrgIsManager?: boolean;
|
||||
baronParentWorksmobileId?: string;
|
||||
baronParentWorksmobileName?: string;
|
||||
baronParentWorksmobileEmail?: string;
|
||||
worksmobileParentId?: string;
|
||||
worksmobileParentName?: string;
|
||||
worksmobileParentEmail?: string;
|
||||
worksmobileParentExternalKey?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -924,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
|
||||
orgUnitId: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/sync`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileOrgUnitDelete(
|
||||
tenantId: string,
|
||||
orgUnitId: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/delete`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -935,7 +935,7 @@ start_import = "Start Import"
|
||||
kicker = "Global Overview"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
description = "Check the graph by all or selected companies."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
|
||||
@@ -937,7 +937,7 @@ start_import = "임포트 시작"
|
||||
kicker = "Global Overview"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
description = "전체 또는 선택한 회사 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
|
||||
@@ -6,7 +6,8 @@ const config: Config = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
"../common/**/*.{ts,tsx,css}",
|
||||
"../common/core/**/*.{ts,tsx}",
|
||||
"../common/shell/**/*.{ts,tsx}",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
80
adminfront/tests/shell_layout.spec.ts
Normal file
80
adminfront/tests/shell_layout.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Admin shell layout", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = route.request().url();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.includes("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/admin/tenants")) {
|
||||
return route.fulfill({
|
||||
json: { items: [], total: 0, limit: 1000, offset: 0 },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps navigation in the left sidebar without covering content", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await page.goto("/tenants");
|
||||
|
||||
const sidebar = page.locator("aside").first();
|
||||
const main = page.locator("main").first();
|
||||
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(main).toBeVisible();
|
||||
|
||||
const sidebarBox = await sidebar.boundingBox();
|
||||
const mainBox = await main.boundingBox();
|
||||
|
||||
expect(sidebarBox).not.toBeNull();
|
||||
expect(mainBox).not.toBeNull();
|
||||
expect(sidebarBox?.x).toBeLessThanOrEqual(1);
|
||||
expect(sidebarBox?.width).toBeLessThanOrEqual(260);
|
||||
expect(mainBox?.x).toBeGreaterThanOrEqual(
|
||||
(sidebarBox?.x ?? 0) + (sidebarBox?.width ?? 0) - 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,79 @@ test.describe("Tenants Management", () => {
|
||||
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
|
||||
});
|
||||
|
||||
test("switches tree and flat views, searches UUID, and selects descendants", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1100, height: 760 });
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "company-1",
|
||||
name: "Hanmac",
|
||||
slug: "hanmac",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "dept-1",
|
||||
name: "Planning",
|
||||
slug: "planning",
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Platform",
|
||||
slug: "platform",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/tenants");
|
||||
|
||||
await expect(page.getByTestId("tenant-view-tree-btn")).toBeVisible();
|
||||
await page.getByTestId("tenant-view-table-btn").click();
|
||||
await expect(page.getByTestId("tenant-view-table-btn")).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
await expect(page.locator("table")).not.toContainText("Hanmac");
|
||||
|
||||
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Hanmac" })
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("tenant-bulk-action-bar")).toContainText(
|
||||
"3개 선택됨",
|
||||
);
|
||||
});
|
||||
|
||||
test("should virtualize large tenant lists and load next pages automatically", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -133,6 +133,24 @@ test.describe("Worksmobile tenant management", () => {
|
||||
worksmobileName: "박웍스",
|
||||
status: "missing_in_baron",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
worksmobileId: "works-hidden-su",
|
||||
externalKey: "works-hidden-su",
|
||||
worksmobileName: "숨김 SU",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
status: "missing_in_baron",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-hidden-cyhan1",
|
||||
baronName: "숨김 CYHAN1",
|
||||
baronEmail: "cyhan1@hanmaceng.co.kr",
|
||||
worksmobileId: "works-hidden-cyhan1",
|
||||
worksmobileName: "숨김 CYHAN1",
|
||||
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
|
||||
status: "matched",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
@@ -148,6 +166,14 @@ test.describe("Worksmobile tenant management", () => {
|
||||
worksmobileName: "박웍스",
|
||||
status: "missing_in_baron",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
worksmobileId: "works-hidden-su",
|
||||
externalKey: "works-hidden-su",
|
||||
worksmobileName: "숨김 SU",
|
||||
worksmobileEmail: "su-@samaneng.com",
|
||||
status: "missing_in_baron",
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
@@ -198,6 +224,10 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect(page.getByText("SCIM token")).not.toBeVisible();
|
||||
await expect(page.getByText("김누락")).toBeVisible();
|
||||
await expect(page.getByText("박웍스")).toBeVisible();
|
||||
await expect(page.getByText("숨김 SU")).not.toBeVisible();
|
||||
await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible();
|
||||
await expect(page.getByText("su-@samaneng.com")).not.toBeVisible();
|
||||
await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible();
|
||||
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
|
||||
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
|
||||
@@ -206,7 +236,16 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||
expect(comparisonRequests[0]).toBe(true);
|
||||
|
||||
const filterButtons = page
|
||||
await page
|
||||
.getByPlaceholder("구성원 이름 또는 UUID 검색")
|
||||
.fill("su-@samaneng.com");
|
||||
await expect(page.getByText("숨김 SU")).not.toBeVisible();
|
||||
await page.getByPlaceholder("구성원 이름 또는 UUID 검색").fill("");
|
||||
|
||||
const userComparisonSection = page
|
||||
.getByRole("heading", { name: "구성원" })
|
||||
.locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]");
|
||||
const filterButtons = userComparisonSection
|
||||
.getByRole("button", {
|
||||
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
|
||||
})
|
||||
@@ -215,12 +254,16 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.poll(() => filterButtons)
|
||||
.toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]);
|
||||
|
||||
await page.getByRole("button", { name: "웍스에만 있음" }).click();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "웍스에만 있음" })
|
||||
.click();
|
||||
await expect(page.getByText("박웍스")).not.toBeVisible();
|
||||
await expect(page.getByText("김누락")).toBeVisible();
|
||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "양쪽 다 있음" })
|
||||
.click();
|
||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
|
||||
@@ -229,22 +272,30 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect(page.getByText("김누락")).toBeVisible();
|
||||
await expect(page.getByText("박웍스")).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "바론에만 있음" }).click();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "바론에만 있음" })
|
||||
.click();
|
||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||
await expect(page.getByText("박웍스")).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "웍스에만 있음" }).click();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "웍스에만 있음" })
|
||||
.click();
|
||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||
await expect(page.getByText("박웍스")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "양쪽 다 있음" })
|
||||
.click();
|
||||
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||
await expect(page.getByText("박웍스")).toBeVisible();
|
||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "바론에만 있음" }).click();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "바론에만 있음" })
|
||||
.click();
|
||||
await expect(page.getByText("김누락")).toBeVisible();
|
||||
await expect(page.getByText("박웍스")).toBeVisible();
|
||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||
@@ -464,11 +515,13 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.getByRole("button", { name: "컬럼 설정" });
|
||||
await userColumnButton.click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" });
|
||||
await dialog.getByLabel("Baron ID").check();
|
||||
await dialog.getByLabel("WORKS ID").check();
|
||||
await dialog.getByLabel("external_key").check();
|
||||
await dialog.getByRole("button", { name: "닫기" }).click();
|
||||
const settingsPanel = page
|
||||
.getByText("구성원 컬럼 설정")
|
||||
.locator("xpath=ancestor::*[@role='dialog'][1]");
|
||||
await settingsPanel.getByLabel("Baron ID").check();
|
||||
await settingsPanel.getByLabel("WORKS", { exact: true }).check();
|
||||
await settingsPanel.getByLabel("external_key").check();
|
||||
await settingsPanel.getByRole("button", { name: "닫기" }).click();
|
||||
|
||||
const pageOverflow = await page.evaluate(() => ({
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
|
||||
@@ -12,4 +12,9 @@ export default defineConfig({
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [".."],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -751,6 +751,7 @@ func main() {
|
||||
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
||||
|
||||
|
||||
@@ -61,6 +61,15 @@ func (h *WorksmobileHandler) SyncOrgUnit(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
|
||||
orgUnitID := strings.TrimSpace(c.Params("orgUnitId"))
|
||||
job, err := h.Service.EnqueueOrgUnitDelete(c.Context(), strings.TrimSpace(c.Params("tenantId")), orgUnitID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "delete_orgunit", "org_unit_id", orgUnitID)
|
||||
}
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
|
||||
|
||||
@@ -112,6 +112,10 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitSync(ctx context.Context, te
|
||||
return &domain.WorksmobileOutbox{ID: "job-orgunit", ResourceID: orgUnitID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
|
||||
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
||||
if f.syncUserErr != nil {
|
||||
return nil, f.syncUserErr
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
type WorksmobileDirectoryClient interface {
|
||||
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
|
||||
UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error
|
||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
@@ -186,6 +187,9 @@ func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, o
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error {
|
||||
if payload.DisplayOrder < 1 {
|
||||
payload.DisplayOrder = 1
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
|
||||
}
|
||||
|
||||
@@ -198,11 +202,12 @@ func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload Works
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
|
||||
localPart := worksmobileMailLocalPart(matchLocalPart)
|
||||
groups, err := c.ListGroups(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalizedMatchLocalPart := worksmobileMailLocalPart(matchLocalPart)
|
||||
var localPartMatch *WorksmobileRemoteGroup
|
||||
for _, group := range groups {
|
||||
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
|
||||
continue
|
||||
@@ -216,43 +221,24 @@ func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx contex
|
||||
}
|
||||
return c.PatchOrgUnit(ctx, group.ID, NewWorksmobileOrgUnitPatchPayload(payload))
|
||||
}
|
||||
}
|
||||
if localPart == "" {
|
||||
return fmt.Errorf("worksmobile orgunit local-part match key is required")
|
||||
}
|
||||
matches := make([]WorksmobileRemoteGroup, 0, 1)
|
||||
for _, group := range groups {
|
||||
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
|
||||
continue
|
||||
}
|
||||
if group.MailLocalPart == localPart {
|
||||
matches = append(matches, group)
|
||||
if normalizedMatchLocalPart != "" && worksmobileMailLocalPart(group.MailLocalPart) == normalizedMatchLocalPart {
|
||||
matched := group
|
||||
if localPartMatch != nil && localPartMatch.ID != matched.ID {
|
||||
return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", normalizedMatchLocalPart)
|
||||
}
|
||||
localPartMatch = &matched
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("worksmobile orgunit local-part match not found: %s", localPart)
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", localPart)
|
||||
}
|
||||
remote := matches[0]
|
||||
if strings.TrimSpace(remote.ID) == "" {
|
||||
return fmt.Errorf("worksmobile orgunit id is missing for local-part: %s", localPart)
|
||||
}
|
||||
if strings.TrimSpace(remote.ExternalID) != "" {
|
||||
if remote.ExternalID == payload.OrgUnitExternalKey {
|
||||
if localPartMatch != nil {
|
||||
if strings.TrimSpace(localPartMatch.ID) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("worksmobile orgunit external key already exists for local-part %s: %s", localPart, remote.ExternalID)
|
||||
if delay := c.orgUnitWriteDelay(); delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
return c.PatchOrgUnit(ctx, localPartMatch.ID, NewWorksmobileOrgUnitPatchPayload(payload))
|
||||
}
|
||||
if delay := c.orgUnitWriteDelay(); delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
patch := NewWorksmobileOrgUnitPatchPayload(payload)
|
||||
if patch.Email == "" {
|
||||
patch.Email = remote.Email
|
||||
}
|
||||
return c.PatchOrgUnit(ctx, remote.ID, patch)
|
||||
return fmt.Errorf("worksmobile orgunit external key match not found after create conflict: %s", payload.OrgUnitExternalKey)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error {
|
||||
|
||||
@@ -325,6 +325,7 @@ func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t
|
||||
require.Len(t, transport.requests, 3)
|
||||
require.Equal(t, http.MethodPost, transport.requests[0].Method)
|
||||
require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path)
|
||||
require.Contains(t, string(transport.requestBodies[0]), `"displayOrder":1`)
|
||||
require.Equal(t, http.MethodGet, transport.requests[1].Method)
|
||||
require.Equal(t, "/v1.0/orgunits", transport.requests[1].URL.Path)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
|
||||
@@ -332,6 +333,34 @@ func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t
|
||||
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertOrgUnitDoesNotBackfillExternalKeyByName(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
|
||||
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"기술개발센터","email":"legacy-tech@samaneng.com"}],"responseMetaData":{}}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300285955},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
OrgUnitWriteDelay: -1,
|
||||
}
|
||||
|
||||
err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{
|
||||
DomainID: 300285955,
|
||||
OrgUnitName: "기술개발센터",
|
||||
OrgUnitExternalKey: "tenant-tech-dev-center",
|
||||
DisplayOrder: 1,
|
||||
}, "tech-dev-center")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "external key match not found")
|
||||
require.Len(t, transport.requests, 2)
|
||||
require.Equal(t, http.MethodGet, transport.requests[1].Method)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertOrgUnitTreatsExistingExternalKeyConflictAsSuccess(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
@@ -463,6 +492,29 @@ func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testin
|
||||
require.Equal(t, []string{"tester@samaneng.com"}, client.activeUsers)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "works-org-1",
|
||||
Action: domain.WorksmobileActionDelete,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{"worksmobileId": "works-org-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
|
||||
}
|
||||
|
||||
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
|
||||
jobs := []domain.WorksmobileOutbox{
|
||||
{
|
||||
@@ -564,8 +616,8 @@ func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
|
||||
parentID := "tenant-parent"
|
||||
childID := "tenant-child"
|
||||
localTenants := []domain.Tenant{
|
||||
{ID: parentID, Name: "기술본부", Type: domain.TenantTypeOrganization},
|
||||
{ID: childID, Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
|
||||
{ID: parentID, Slug: "tech-hq", Name: "기술본부", Type: domain.TenantTypeOrganization},
|
||||
{ID: childID, Slug: "tech-planning", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
|
||||
}
|
||||
remoteGroups := []WorksmobileRemoteGroup{
|
||||
{
|
||||
@@ -589,7 +641,9 @@ func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
|
||||
items := compareWorksmobileGroups(localTenants, remoteGroups, true)
|
||||
|
||||
require.Len(t, items, 2)
|
||||
require.Equal(t, "tech-planning", items[1].BaronSlug)
|
||||
require.Equal(t, parentID, items[1].BaronParentID)
|
||||
require.Equal(t, "tech-hq", items[1].BaronParentSlug)
|
||||
require.Equal(t, "기술본부", items[1].BaronParentName)
|
||||
require.Equal(t, int64(300286337), items[1].WorksmobileDomainID)
|
||||
require.Equal(t, "총괄기획&기술개발센터", items[1].WorksmobileDomainName)
|
||||
@@ -638,7 +692,7 @@ func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenInclu
|
||||
require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *testing.T) {
|
||||
func TestCompareWorksmobileGroupsDoesNotMatchBySlugLocalPartWhenExternalIDMissing(t *testing.T) {
|
||||
localTenants := []domain.Tenant{
|
||||
{
|
||||
ID: "tenant-tech-dev-center",
|
||||
@@ -659,12 +713,75 @@ func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *
|
||||
diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false)
|
||||
all := compareWorksmobileGroups(localTenants, remoteGroups, true)
|
||||
|
||||
require.Empty(t, diffOnly)
|
||||
require.Len(t, all, 1)
|
||||
require.Equal(t, "matched", all[0].Status)
|
||||
require.Len(t, diffOnly, 2)
|
||||
require.Equal(t, "missing_in_worksmobile", diffOnly[0].Status)
|
||||
require.Equal(t, "tenant-tech-dev-center", diffOnly[0].BaronID)
|
||||
require.Equal(t, "missing_external_key", diffOnly[1].Status)
|
||||
require.Equal(t, "works-org-1", diffOnly[1].WorksmobileID)
|
||||
require.Len(t, all, 2)
|
||||
require.Equal(t, "missing_in_worksmobile", all[0].Status)
|
||||
require.Equal(t, "tenant-tech-dev-center", all[0].BaronID)
|
||||
require.Equal(t, "works-org-1", all[0].WorksmobileID)
|
||||
require.Empty(t, all[0].ExternalKey)
|
||||
require.Equal(t, "missing_external_key", all[1].Status)
|
||||
require.Equal(t, "works-org-1", all[1].WorksmobileID)
|
||||
require.Empty(t, all[1].ExternalKey)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileGroupsDoesNotMatchByNameWhenExternalIDAndSlugAreMissing(t *testing.T) {
|
||||
localTenants := []domain.Tenant{
|
||||
{
|
||||
ID: "tenant-tech-dev-center",
|
||||
Slug: "tech-dev-center",
|
||||
Name: "기술개발센터",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
}
|
||||
remoteGroups := []WorksmobileRemoteGroup{
|
||||
{
|
||||
ID: "works-org-1",
|
||||
DisplayName: "기술개발센터",
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileGroups(localTenants, remoteGroups, false)
|
||||
|
||||
require.Len(t, items, 2)
|
||||
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||
require.Equal(t, "tenant-tech-dev-center", items[0].BaronID)
|
||||
require.Equal(t, "missing_external_key", items[1].Status)
|
||||
require.Equal(t, "works-org-1", items[1].WorksmobileID)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileGroupsListsExternalKeyMissingRowsAsDeleteCandidatesAcrossDomains(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||
rootID := "root-tenant"
|
||||
samanID := "company-saman"
|
||||
hanmacID := "company-hanmac"
|
||||
localTenants := []domain.Tenant{
|
||||
{ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
{ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
{ID: hanmacID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
|
||||
{ID: "tenant-saman-planning", Slug: "planning", Name: "기획팀", Type: domain.TenantTypeOrganization, ParentID: &samanID},
|
||||
{ID: "tenant-hanmac-planning", Slug: "planning", Name: "기획팀", Type: domain.TenantTypeOrganization, ParentID: &hanmacID},
|
||||
}
|
||||
remoteGroups := []WorksmobileRemoteGroup{
|
||||
{ID: "works-saman-planning", DomainID: 1001, DisplayName: "기획팀", MailLocalPart: "planning"},
|
||||
{ID: "works-hanmac-planning", DomainID: 1002, DisplayName: "기획팀", MailLocalPart: "planning"},
|
||||
}
|
||||
|
||||
items := compareWorksmobileGroups(localTenants, remoteGroups, false)
|
||||
|
||||
require.Len(t, items, 4)
|
||||
require.Equal(t, "tenant-saman-planning", items[0].BaronID)
|
||||
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||
require.Equal(t, "tenant-hanmac-planning", items[1].BaronID)
|
||||
require.Equal(t, "missing_in_worksmobile", items[1].Status)
|
||||
require.Equal(t, "works-saman-planning", items[2].WorksmobileID)
|
||||
require.Equal(t, "missing_external_key", items[2].Status)
|
||||
require.Equal(t, "works-hanmac-planning", items[3].WorksmobileID)
|
||||
require.Equal(t, "missing_external_key", items[3].Status)
|
||||
}
|
||||
|
||||
func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) {
|
||||
@@ -802,11 +919,13 @@ func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, m
|
||||
|
||||
type fakeWorksmobileDirectoryClient struct {
|
||||
createdOrgUnits []WorksmobileOrgUnitPayload
|
||||
deletedOrgUnits []string
|
||||
createdUsers []WorksmobileUserPayload
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
orgUnitMatchKeys []string
|
||||
groups []WorksmobileRemoteGroup
|
||||
}
|
||||
|
||||
type captureRoundTripper struct {
|
||||
@@ -880,6 +999,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertOrgUnit(ctx context.Context, payl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
|
||||
f.deletedOrgUnits = append(f.deletedOrgUnits, orgUnitID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
f.createdUsers = append(f.createdUsers, payload)
|
||||
return nil
|
||||
@@ -909,5 +1033,5 @@ func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]Works
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
|
||||
return nil, nil
|
||||
return f.groups, nil
|
||||
}
|
||||
|
||||
@@ -126,6 +126,13 @@ func TestWorksmobileLiveGPDTDCOrgUnitProvisioning(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) {
|
||||
if os.Getenv("WORKSMOBILE_LIVE_BARONGROUP_ORGUNIT_PROVISIONING") != "1" {
|
||||
t.Skip("live Worksmobile Baron Group orgunit provisioning is disabled")
|
||||
}
|
||||
runWorksmobileLiveBaronGroupOrgUnitProvisioning(t)
|
||||
}
|
||||
|
||||
func TestWorksmobileLiveSyncHanmacFamilyOrgUnits(t *testing.T) {
|
||||
if os.Getenv("WORKSMOBILE_LIVE_SYNC_HANMAC_FAMILY_ORGUNITS") != "1" {
|
||||
t.Skip("live Worksmobile Hanmac family orgunit sync is disabled")
|
||||
@@ -548,6 +555,142 @@ func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug stri
|
||||
}
|
||||
}
|
||||
|
||||
func runWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil)
|
||||
client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
||||
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
||||
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
||||
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
||||
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
||||
})
|
||||
|
||||
baronGroup, err := tenantService.GetTenantBySlug(ctx, "baron-group")
|
||||
require.NoError(t, err)
|
||||
tenants, err := listWorksmobileLiveTenantScope(db, baronGroup.ID)
|
||||
require.NoError(t, err)
|
||||
domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID")
|
||||
require.True(t, ok, "missing BARONGROUP_DOMAIN_ID")
|
||||
mailDomain := getenvDefault("BARONGROUP_MAIL_DOMAIN", getenvDefault("WORKS_DEFAULT_DOMAIN_BARONGROUP", "brsw.kr"))
|
||||
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*baronGroup}, tenants...))
|
||||
targets := worksmobileLiveBaronGroupOrgUnitTargets(t, tenants, tenantByID, *baronGroup, domainID, mailDomain)
|
||||
targetByID := map[string]worksmobileLiveOrgUnitTarget{}
|
||||
for _, target := range targets {
|
||||
targetByID[target.Tenant.ID] = target
|
||||
}
|
||||
|
||||
remoteGroups, err := client.ListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
remoteByExternalID, duplicateExternalKeys := worksmobileLiveRemoteByExternalID(remoteGroups)
|
||||
require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
|
||||
|
||||
for _, target := range sortWorksmobileLiveTargetsTopologically(targets, tenantByID) {
|
||||
remote, found := remoteByExternalID[target.Tenant.ID]
|
||||
if found && remote.DomainID != target.Payload.DomainID {
|
||||
t.Logf("REKEY conflicting external key slug=%s external=%s worksID=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.ID, remote.DomainID, target.Payload.DomainID)
|
||||
if err := client.ClearOrgUnitExternalKey(ctx, remote.ID, remote.DomainID); err != nil {
|
||||
legacyPatch := WorksmobileOrgUnitPatchPayload{
|
||||
DomainID: remote.DomainID,
|
||||
OrgUnitExternalKey: "legacy-" + remote.ID,
|
||||
}
|
||||
require.NoError(t, client.PatchOrgUnit(ctx, remote.ID, legacyPatch))
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
remoteGroups, err = client.ListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups)
|
||||
require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
|
||||
remote, found = remoteByExternalID[target.Tenant.ID]
|
||||
if found && remote.DomainID != target.Payload.DomainID {
|
||||
require.Failf(t, "external key is attached to a different Worksmobile domain after rekey", "slug=%s external=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.DomainID, target.Payload.DomainID)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
remote, found = findWorksmobileLiveRemoteByPath(remoteGroups, worksmobileLiveRemoteByID(remoteGroups), target.Payload.DomainID, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID))
|
||||
}
|
||||
if found {
|
||||
t.Logf("PATCH orgunit slug=%s id=%s worksID=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, remote.ID, target.Payload.Email, target.Payload.ParentOrgUnitID)
|
||||
require.NoError(t, patchWorksmobileLiveOrgUnit(ctx, client, remote.ID, target.Payload))
|
||||
} else {
|
||||
t.Logf("CREATE orgunit slug=%s id=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, target.Payload.Email, target.Payload.ParentOrgUnitID)
|
||||
require.NoError(t, client.UpsertOrgUnit(ctx, target.Payload, target.Tenant.Slug))
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
remoteGroups, err = client.ListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups)
|
||||
require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
|
||||
}
|
||||
|
||||
remoteGroups, err = client.ListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups)
|
||||
require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
|
||||
remoteByID := worksmobileLiveRemoteByID(remoteGroups)
|
||||
for _, target := range targets {
|
||||
remote, ok := remoteByExternalID[target.Tenant.ID]
|
||||
require.True(t, ok, "missing Worksmobile orgunit after sync: %s", target.Tenant.Slug)
|
||||
require.Equal(t, target.Payload.DomainID, remote.DomainID, "domain mismatch: %s", target.Tenant.Slug)
|
||||
require.Equal(t, target.Tenant.Name, remote.DisplayName, "name mismatch: %s", target.Tenant.Slug)
|
||||
require.Equal(t, worksmobileMailLocalPart(target.Payload.Email), remote.MailLocalPart, "email local-part mismatch: %s", target.Tenant.Slug)
|
||||
require.Equal(t, strings.ToLower(strings.TrimSpace(target.Payload.Email)), strings.ToLower(strings.TrimSpace(remote.Email)), "email mismatch: %s", target.Tenant.Slug)
|
||||
expectedParentID := ""
|
||||
if parentExternalKey := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentExternalKey != "" && parentExternalKey != target.Payload.ParentOrgUnitID {
|
||||
parentRemote, ok := remoteByExternalID[parentExternalKey]
|
||||
require.True(t, ok, "missing Worksmobile parent for %s", target.Tenant.Slug)
|
||||
expectedParentID = parentRemote.ID
|
||||
parentTarget, ok := targetByID[parentExternalKey]
|
||||
require.True(t, ok, "missing Baron parent target for %s", target.Tenant.Slug)
|
||||
require.Equal(t, worksmobileMailLocalPart(parentTarget.Payload.Email), parentRemote.MailLocalPart, "parent email local-part mismatch: %s", target.Tenant.Slug)
|
||||
}
|
||||
require.Equal(t, expectedParentID, remote.ParentID, "parent mismatch: %s", target.Tenant.Slug)
|
||||
require.Equal(t, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID), worksmobileLiveRemotePath(remoteByID, remote), "path mismatch: %s", target.Tenant.Slug)
|
||||
}
|
||||
|
||||
t.Logf("SUMMARY synced=%d domainID=%d", len(targets), domainID)
|
||||
}
|
||||
|
||||
func worksmobileLiveBaronGroupOrgUnitTargets(t *testing.T, tenants []domain.Tenant, tenantByID map[string]domain.Tenant, root domain.Tenant, domainID int64, mailDomain string) []worksmobileLiveOrgUnitTarget {
|
||||
t.Helper()
|
||||
mailDomain = strings.ToLower(strings.TrimSpace(mailDomain))
|
||||
require.NotEmpty(t, mailDomain, "baron group mail domain is required")
|
||||
targets := make([]worksmobileLiveOrgUnitTarget, 0)
|
||||
seenExternalKeys := map[string]string{}
|
||||
seenEmails := map[string]string{}
|
||||
for index, tenant := range tenants {
|
||||
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) || worksmobileLiveSkipOrgUnitTenant(tenant) {
|
||||
continue
|
||||
}
|
||||
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, root, root.Config, index+1)
|
||||
require.NoError(t, err, "payload build failed: %s", tenant.Slug)
|
||||
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
|
||||
payload.DomainID = domainID
|
||||
payload.Email = strings.ToLower(strings.TrimSpace(tenant.Slug)) + "@" + mailDomain
|
||||
require.NotEmpty(t, payload.Email, "orgunit email is required: %s", tenant.Slug)
|
||||
if owner, exists := seenExternalKeys[payload.OrgUnitExternalKey]; exists {
|
||||
require.Failf(t, "duplicate Baron external key", "external=%s owner=%s duplicate=%s", payload.OrgUnitExternalKey, owner, tenant.Slug)
|
||||
}
|
||||
seenExternalKeys[payload.OrgUnitExternalKey] = tenant.Slug
|
||||
normalizedEmail := strings.ToLower(strings.TrimSpace(payload.Email))
|
||||
if owner, exists := seenEmails[normalizedEmail]; exists {
|
||||
require.Failf(t, "duplicate Baron orgunit email", "email=%s owner=%s duplicate=%s", normalizedEmail, owner, tenant.Slug)
|
||||
}
|
||||
seenEmails[normalizedEmail] = tenant.Slug
|
||||
targets = append(targets, worksmobileLiveOrgUnitTarget{Tenant: tenant, Payload: payload})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) {
|
||||
t.Helper()
|
||||
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1)
|
||||
|
||||
@@ -72,6 +72,9 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
|
||||
if err := ValidateWorksmobileExternalKey(tenant.ID); err != nil {
|
||||
return WorksmobileOrgUnitPayload{}, err
|
||||
}
|
||||
if displayOrder < 1 {
|
||||
displayOrder = 1
|
||||
}
|
||||
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileOrgUnitPayload{}, err
|
||||
|
||||
@@ -56,6 +56,21 @@ func TestBuildWorksmobileOrgUnitPayloadUsesWorksmobileMailDomainForBarongroup(t
|
||||
require.Equal(t, "jangheon@brsw.kr", payload.Email)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileOrgUnitPayloadDefaultsDisplayOrderToOne(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenant := domain.Tenant{
|
||||
ID: "11111111-1111-1111-1111-111111111111",
|
||||
Slug: "tech-dev-center",
|
||||
Name: "기술개발센터",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, payload.DisplayOrder)
|
||||
}
|
||||
|
||||
func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) {
|
||||
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
|
||||
payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID}
|
||||
|
||||
@@ -82,6 +82,9 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
|
||||
switch job.ResourceType {
|
||||
case domain.WorksmobileResourceOrgUnit:
|
||||
if job.Action == domain.WorksmobileActionDelete {
|
||||
return w.client.DeleteOrgUnit(ctx, stringValue(job.Payload["worksmobileId"]))
|
||||
}
|
||||
if job.Action != domain.WorksmobileActionUpsert {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type WorksmobileAdminService interface {
|
||||
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
|
||||
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
|
||||
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
|
||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||
@@ -62,11 +63,14 @@ type WorksmobileComparison struct {
|
||||
type WorksmobileComparisonItem struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
@@ -82,8 +86,13 @@ type WorksmobileComparisonItem struct {
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -243,16 +252,21 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
|
||||
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
|
||||
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
|
||||
}
|
||||
return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root *domain.Tenant, tenant domain.Tenant, scopeTenants []domain.Tenant) (*domain.WorksmobileOutbox, error) {
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
|
||||
*tenant,
|
||||
worksmobileDomainClassificationTenant(*tenant, tenantByID),
|
||||
tenant,
|
||||
worksmobileDomainClassificationTenant(tenant, tenantByID),
|
||||
root.Config,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = normalizeWorksmobileOrgUnitParent(payload, *tenant, tenantByID, root.ID)
|
||||
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: tenant.ID,
|
||||
@@ -269,6 +283,62 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.client == nil {
|
||||
return nil, errors.New("worksmobile client is not configured")
|
||||
}
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worksmobileOrgUnitID = strings.TrimSpace(worksmobileOrgUnitID)
|
||||
if worksmobileOrgUnitID == "" {
|
||||
return nil, errors.New("worksmobile orgunit id is required")
|
||||
}
|
||||
groups, err := s.client.ListGroups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var target *WorksmobileRemoteGroup
|
||||
for i := range groups {
|
||||
if strings.TrimSpace(groups[i].ID) == worksmobileOrgUnitID {
|
||||
target = &groups[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errors.New("worksmobile orgunit not found")
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok {
|
||||
return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants)
|
||||
}
|
||||
if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
|
||||
return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted")
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: worksmobileOrgUnitID,
|
||||
Action: domain.WorksmobileActionDelete,
|
||||
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
|
||||
Payload: domain.JSONMap{
|
||||
"worksmobileId": worksmobileOrgUnitID,
|
||||
"externalKey": target.ExternalID,
|
||||
"domainId": target.DomainID,
|
||||
"name": target.DisplayName,
|
||||
"email": target.Email,
|
||||
},
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -585,6 +655,53 @@ func addWorksmobileLocalPart(target map[string]string, email string, owner strin
|
||||
}
|
||||
}
|
||||
|
||||
func findWorksmobileOrgUnitTenantByRemoteLocalPart(remote WorksmobileRemoteGroup, localTenants []domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||
candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
|
||||
for _, tenant := range localTenants {
|
||||
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
||||
continue
|
||||
}
|
||||
if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
|
||||
return tenant, true
|
||||
}
|
||||
}
|
||||
return domain.Tenant{}, false
|
||||
}
|
||||
|
||||
func isProtectedWorksmobileRemoteOrgUnit(root domain.Tenant, localTenants []domain.Tenant, remote WorksmobileRemoteGroup) bool {
|
||||
if strings.TrimSpace(remote.ParentID) == "" {
|
||||
return true
|
||||
}
|
||||
candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
|
||||
if len(candidates) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, tenant := range localTenants {
|
||||
if tenant.ParentID == nil || *tenant.ParentID != root.ID || tenant.Type != domain.TenantTypeCompany {
|
||||
continue
|
||||
}
|
||||
if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileRemoteGroupLocalPartCandidates(remote WorksmobileRemoteGroup) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
if localPart := normalizeWorksmobileSlugLocalPart(remote.MailLocalPart); localPart != "" {
|
||||
result[localPart] = true
|
||||
}
|
||||
if localPart, err := domain.ExtractNormalizedEmailLocalPart(remote.Email); err == nil && localPart != "" {
|
||||
result[localPart] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWorksmobileSlugLocalPart(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
||||
if tenant.Type == domain.TenantTypeOrganization {
|
||||
return true
|
||||
@@ -703,6 +820,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
BaronName: user.Name,
|
||||
BaronEmail: user.Email,
|
||||
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
|
||||
BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
|
||||
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
||||
Status: "missing_in_worksmobile",
|
||||
}
|
||||
@@ -796,24 +914,30 @@ func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]dom
|
||||
return ""
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrgSlug(user domain.User, localTenants map[string]domain.Tenant) string {
|
||||
tenantID := worksmobileUserPrimaryOrgID(user)
|
||||
if tenantID == "" {
|
||||
return ""
|
||||
}
|
||||
if tenant, ok := localTenants[tenantID]; ok {
|
||||
return strings.TrimSpace(tenant.Slug)
|
||||
}
|
||||
if user.Tenant != nil && user.Tenant.ID == tenantID {
|
||||
return strings.TrimSpace(user.Tenant.Slug)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
|
||||
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
|
||||
remoteByMailLocalPart := map[string]WorksmobileRemoteGroup{}
|
||||
ambiguousMailLocalParts := map[string]bool{}
|
||||
remoteByID := map[string]WorksmobileRemoteGroup{}
|
||||
for _, remote := range remoteGroups {
|
||||
if remote.ID != "" {
|
||||
remoteByID[remote.ID] = remote
|
||||
}
|
||||
if remote.ExternalID != "" {
|
||||
remoteByExternalID[remote.ExternalID] = remote
|
||||
}
|
||||
if remote.ExternalID == "" && remote.MailLocalPart != "" {
|
||||
if _, exists := remoteByMailLocalPart[remote.MailLocalPart]; exists {
|
||||
delete(remoteByMailLocalPart, remote.MailLocalPart)
|
||||
ambiguousMailLocalParts[remote.MailLocalPart] = true
|
||||
continue
|
||||
}
|
||||
if !ambiguousMailLocalParts[remote.MailLocalPart] {
|
||||
remoteByMailLocalPart[remote.MailLocalPart] = remote
|
||||
}
|
||||
}
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(localTenants)
|
||||
localByID := map[string]domain.Tenant{}
|
||||
@@ -827,9 +951,6 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
||||
}
|
||||
localByID[tenant.ID] = tenant
|
||||
remote, matched := remoteByExternalID[tenant.ID]
|
||||
if !matched {
|
||||
remote, matched = remoteByMailLocalPart[worksmobileMailLocalPart(tenant.Slug)]
|
||||
}
|
||||
if matched && !includeMatched {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
@@ -837,8 +958,10 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
||||
item := WorksmobileComparisonItem{
|
||||
ResourceType: "GROUP",
|
||||
BaronID: tenant.ID,
|
||||
BaronSlug: tenant.Slug,
|
||||
BaronName: tenant.Name,
|
||||
BaronParentID: worksmobileTenantParentID(tenant),
|
||||
BaronParentSlug: worksmobileTenantParentSlug(tenant, tenantByID),
|
||||
BaronParentName: worksmobileTenantParentName(tenant, tenantByID),
|
||||
Status: "missing_in_worksmobile",
|
||||
}
|
||||
@@ -852,6 +975,19 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
||||
item.WorksmobileDomainName = remote.DomainName
|
||||
item.WorksmobileParentID = remote.ParentID
|
||||
item.WorksmobileParentName = remote.ParentName
|
||||
if parentRemote, ok := remoteByExternalID[item.BaronParentID]; ok {
|
||||
item.BaronParentWorksmobileID = parentRemote.ID
|
||||
item.BaronParentWorksmobileName = parentRemote.DisplayName
|
||||
item.BaronParentWorksmobileEmail = parentRemote.Email
|
||||
}
|
||||
if parentRemote, ok := remoteByID[remote.ParentID]; ok {
|
||||
if item.WorksmobileParentName == "" {
|
||||
item.WorksmobileParentName = parentRemote.DisplayName
|
||||
}
|
||||
item.WorksmobileParentEmail = parentRemote.Email
|
||||
item.WorksmobileParentExternalKey = parentRemote.ExternalID
|
||||
}
|
||||
item = fillWorksmobileParentFromBaronParentMatch(item)
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
}
|
||||
result = append(result, item)
|
||||
@@ -873,6 +1009,14 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
||||
WorksmobileParentName: remote.ParentName,
|
||||
Status: "missing_external_key",
|
||||
})
|
||||
if parentRemote, ok := remoteByID[remote.ParentID]; ok {
|
||||
last := &result[len(result)-1]
|
||||
if last.WorksmobileParentName == "" {
|
||||
last.WorksmobileParentName = parentRemote.DisplayName
|
||||
}
|
||||
last.WorksmobileParentEmail = parentRemote.Email
|
||||
last.WorksmobileParentExternalKey = parentRemote.ExternalID
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ignoredLocalByID[remote.ExternalID] {
|
||||
@@ -891,11 +1035,35 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
||||
WorksmobileParentName: remote.ParentName,
|
||||
Status: "missing_in_baron",
|
||||
})
|
||||
if parentRemote, ok := remoteByID[remote.ParentID]; ok {
|
||||
last := &result[len(result)-1]
|
||||
if last.WorksmobileParentName == "" {
|
||||
last.WorksmobileParentName = parentRemote.DisplayName
|
||||
}
|
||||
last.WorksmobileParentEmail = parentRemote.Email
|
||||
last.WorksmobileParentExternalKey = parentRemote.ExternalID
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem {
|
||||
if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID {
|
||||
return item
|
||||
}
|
||||
if item.WorksmobileParentName == "" {
|
||||
item.WorksmobileParentName = item.BaronParentWorksmobileName
|
||||
}
|
||||
if item.WorksmobileParentEmail == "" {
|
||||
item.WorksmobileParentEmail = item.BaronParentWorksmobileEmail
|
||||
}
|
||||
if item.WorksmobileParentExternalKey == "" {
|
||||
item.WorksmobileParentExternalKey = item.BaronParentID
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func worksmobileTenantByID(tenants []domain.Tenant) map[string]domain.Tenant {
|
||||
result := make(map[string]domain.Tenant, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
@@ -918,3 +1086,11 @@ func worksmobileTenantParentName(tenant domain.Tenant, tenantByID map[string]dom
|
||||
}
|
||||
return strings.TrimSpace(tenantByID[parentID].Name)
|
||||
}
|
||||
|
||||
func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string {
|
||||
parentID := worksmobileTenantParentID(tenant)
|
||||
if parentID == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(tenantByID[parentID].Slug)
|
||||
}
|
||||
|
||||
@@ -166,10 +166,10 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
|
||||
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup},
|
||||
[]WorksmobileRemoteGroup{
|
||||
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
|
||||
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name},
|
||||
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name, Email: "hanmac@hanmaceng.co.kr"},
|
||||
{ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name},
|
||||
{ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name},
|
||||
{ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name},
|
||||
{ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name, ParentID: "works-hanmac"},
|
||||
{ID: "works-legacy-user-group", ExternalID: legacyUserGroup.ID, DisplayName: legacyUserGroup.Name},
|
||||
{ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
|
||||
},
|
||||
@@ -181,6 +181,13 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.Equal(t, organization.ID, items[1].BaronID)
|
||||
require.Equal(t, "matched", items[1].Status)
|
||||
require.Equal(t, "works-hanmac", items[1].WorksmobileParentID)
|
||||
require.Equal(t, hanmac.Name, items[1].WorksmobileParentName)
|
||||
require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].WorksmobileParentEmail)
|
||||
require.Equal(t, hanmac.ID, items[1].WorksmobileParentExternalKey)
|
||||
require.Equal(t, "works-hanmac", items[1].BaronParentWorksmobileID)
|
||||
require.Equal(t, hanmac.Name, items[1].BaronParentWorksmobileName)
|
||||
require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].BaronParentWorksmobileEmail)
|
||||
require.Equal(t, "works-orphan", items[2].ExternalKey)
|
||||
require.Equal(t, "missing_in_baron", items[2].Status)
|
||||
}
|
||||
@@ -304,6 +311,381 @@ func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
|
||||
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.Equal(t, organizationID, request.OrgUnitExternalKey)
|
||||
require.Empty(t, request.ParentOrgUnitID)
|
||||
require.Equal(t, 1, request.DisplayOrder)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesExternalKeyMissingOrgUnitDelete(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
client := &fakeWorksmobileDirectoryClient{
|
||||
groups: []WorksmobileRemoteGroup{
|
||||
{
|
||||
ID: "works-org-1",
|
||||
DisplayName: "WORKS 전용 조직",
|
||||
DomainID: 1001,
|
||||
ParentID: "works-parent",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
client,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
|
||||
require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
client := &fakeWorksmobileDirectoryClient{
|
||||
groups: []WorksmobileRemoteGroup{
|
||||
{
|
||||
ID: "works-org-1",
|
||||
ExternalID: "baron-tenant-1",
|
||||
ParentID: "works-parent",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
client,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
|
||||
require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
orgID := "baron-org-1"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
organization := domain.Tenant{
|
||||
ID: orgID,
|
||||
Slug: "tech-dev-center",
|
||||
Name: "기술개발센터",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
client := &fakeWorksmobileDirectoryClient{
|
||||
groups: []WorksmobileRemoteGroup{
|
||||
{
|
||||
ID: "works-org-1",
|
||||
ExternalID: "legacy-external-key",
|
||||
DisplayName: "기술개발센터",
|
||||
MailLocalPart: "tech-dev-center",
|
||||
DomainID: 1001,
|
||||
ParentID: "works-parent",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{rootID: root, orgID: organization},
|
||||
list: []domain.Tenant{root, organization},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
client,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
|
||||
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.Equal(t, orgID, request.OrgUnitExternalKey)
|
||||
require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
parent := domain.Tenant{
|
||||
ID: "parent-tenant",
|
||||
Name: "삼안",
|
||||
Slug: "saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: "child-tenant",
|
||||
Name: "업무",
|
||||
Slug: "operations",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &parent.ID,
|
||||
}
|
||||
|
||||
items := compareWorksmobileGroups(
|
||||
[]domain.Tenant{
|
||||
{ID: rootID, Name: "한맥가족", Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup},
|
||||
parent,
|
||||
child,
|
||||
},
|
||||
[]WorksmobileRemoteGroup{
|
||||
{ID: "works-parent", ExternalID: parent.ID, DisplayName: "삼안", Email: "saman@samaneng.com"},
|
||||
{ID: "works-child", ExternalID: child.ID, DisplayName: "업무", ParentID: "works-parent"},
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, child.ID, items[0].BaronID)
|
||||
require.Equal(t, "works-parent", items[0].WorksmobileParentID)
|
||||
require.Equal(t, "삼안", items[0].WorksmobileParentName)
|
||||
require.Equal(t, "saman@samaneng.com", items[0].WorksmobileParentEmail)
|
||||
require.Equal(t, parent.ID, items[0].WorksmobileParentExternalKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtectedDeleteGuard(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
orgID := "baron-operations"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
samanID := "saman-tenant"
|
||||
saman := domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
organization := domain.Tenant{
|
||||
ID: orgID,
|
||||
Slug: "operations",
|
||||
Name: "업무",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &samanID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
client := &fakeWorksmobileDirectoryClient{
|
||||
groups: []WorksmobileRemoteGroup{
|
||||
{
|
||||
ID: "works-operations",
|
||||
ExternalID: "legacy-operations-id",
|
||||
DisplayName: "업무팀",
|
||||
Email: "operations@samaneng.com",
|
||||
MailLocalPart: "operations",
|
||||
DomainID: 1001,
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{rootID: root, samanID: saman, orgID: organization},
|
||||
list: []domain.Tenant{root, saman, organization},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
client,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-operations")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
|
||||
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.Equal(t, orgID, request.OrgUnitExternalKey)
|
||||
require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
client := &fakeWorksmobileDirectoryClient{
|
||||
groups: []WorksmobileRemoteGroup{
|
||||
{
|
||||
ID: "works-root",
|
||||
DisplayName: "한맥기술",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
client,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-root")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "protected worksmobile domain root")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
company domain.Tenant
|
||||
organization domain.Tenant
|
||||
wantDomainID int64
|
||||
wantEmail string
|
||||
}{
|
||||
{
|
||||
name: "saman",
|
||||
company: domain.Tenant{
|
||||
ID: "company-saman",
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
},
|
||||
organization: domain.Tenant{
|
||||
ID: "org-saman-planning",
|
||||
Slug: "saman-planning",
|
||||
Name: "삼안 기획팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
wantDomainID: 1001,
|
||||
wantEmail: "saman-planning@samaneng.com",
|
||||
},
|
||||
{
|
||||
name: "hanmac",
|
||||
company: domain.Tenant{
|
||||
ID: "company-hanmac",
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
|
||||
},
|
||||
organization: domain.Tenant{
|
||||
ID: "org-hanmac-planning",
|
||||
Slug: "hanmac-planning",
|
||||
Name: "한맥 기획팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
wantDomainID: 1002,
|
||||
wantEmail: "hanmac-planning@hanmaceng.co.kr",
|
||||
},
|
||||
{
|
||||
name: "gpdtdc",
|
||||
company: domain.Tenant{
|
||||
ID: "company-gpdtdc",
|
||||
Slug: "gpdtdc",
|
||||
Name: "총괄기획&기술개발센터",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
},
|
||||
organization: domain.Tenant{
|
||||
ID: "org-gpdtdc-planning",
|
||||
Slug: "gpdtdc-planning",
|
||||
Name: "총괄 기획팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
wantDomainID: 1003,
|
||||
wantEmail: "gpdtdc-planning@baroncs.co.kr",
|
||||
},
|
||||
{
|
||||
name: "baron-group",
|
||||
company: domain.Tenant{
|
||||
ID: "company-barongroup",
|
||||
Slug: "baron-group",
|
||||
Name: "바론그룹",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
},
|
||||
organization: domain.Tenant{
|
||||
ID: "org-baron-planning",
|
||||
Slug: "baron-planning",
|
||||
Name: "바론 기획팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
wantDomainID: 1004,
|
||||
wantEmail: "baron-planning@brsw.kr",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
organization := tt.organization
|
||||
organization.ParentID = &tt.company.ID
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{
|
||||
rootID: root,
|
||||
tt.company.ID: tt.company,
|
||||
organization.ID: organization,
|
||||
},
|
||||
list: []domain.Tenant{root, tt.company, organization},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.Equal(t, organization.ID, request.OrgUnitExternalKey)
|
||||
require.Equal(t, tt.wantDomainID, request.DomainID)
|
||||
require.Equal(t, tt.wantEmail, request.Email)
|
||||
require.Empty(t, request.ParentOrgUnitID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, type UserConfig } from "vite";
|
||||
import { type UserConfig, defineConfig } from "vite";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const commonWorkspaceDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const appWorkspaceDir = path.resolve(process.cwd());
|
||||
const reactPackageDir = path.dirname(require.resolve("react/package.json"));
|
||||
const reactDomPackageDir = path.dirname(
|
||||
require.resolve("react-dom/package.json"),
|
||||
@@ -23,6 +29,11 @@ export const commonViteConfig: UserConfig = {
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [appWorkspaceDir, commonWorkspaceDir, "/workspace/common"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function hostFromUrl(value: string | undefined) {
|
||||
@@ -37,7 +48,7 @@ export function hostFromUrl(value: string | undefined) {
|
||||
export function getAllowedHosts(
|
||||
defaultHosts: string[],
|
||||
envUrl?: string,
|
||||
envAllowedHosts?: string
|
||||
envAllowedHosts?: string,
|
||||
) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
@@ -48,8 +59,8 @@ export function getAllowedHosts(
|
||||
.split(",")
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean),
|
||||
].filter((host): host is string => Boolean(host))
|
||||
)
|
||||
].filter((host): host is string => Boolean(host)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const shellLayoutClasses = {
|
||||
root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
|
||||
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
|
||||
aside:
|
||||
"flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur",
|
||||
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||
asideStatic:
|
||||
"border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur",
|
||||
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
|
||||
brandSection:
|
||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||
@@ -24,7 +24,8 @@ export const shellLayoutClasses = {
|
||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||
logoutButton:
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
header:
|
||||
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerElevated:
|
||||
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||
|
||||
@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
# If common workspace exists, manage dependencies from there
|
||||
if [ -d /common ] && [ -f /common/package.json ]; then
|
||||
WORKSPACE_DIR="/common"
|
||||
LOCK_FILE="/common/pnpm-lock.yaml"
|
||||
APP_WORKSPACE_FILTER="../devfront"
|
||||
|
||||
# If common workspace exists, manage dependencies from the real workspace tree.
|
||||
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
|
||||
WORKSPACE_DIR="/workspace/common"
|
||||
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
if [ "$WORKSPACE_DIR" = "/common" ]; then
|
||||
|
||||
(cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
@@ -6,7 +6,8 @@ const config: Config = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
"../common/**/*.{ts,tsx,css}",
|
||||
"../common/core/**/*.{ts,tsx}",
|
||||
"../common/shell/**/*.{ts,tsx}",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -61,10 +61,13 @@ services:
|
||||
ports:
|
||||
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||
volumes:
|
||||
- ./adminfront:/app
|
||||
- ./adminfront:/workspace/adminfront
|
||||
- ./common:/common
|
||||
- ./common:/workspace/common
|
||||
- /workspace/common/node_modules
|
||||
- ./locales:/locales
|
||||
- /app/node_modules
|
||||
- ./locales:/workspace/locales
|
||||
- /workspace/adminfront/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
@@ -82,10 +85,13 @@ services:
|
||||
ports:
|
||||
- "${DEVFRONT_PORT:-5174}:5173"
|
||||
volumes:
|
||||
- ./devfront:/app
|
||||
- ./devfront:/workspace/devfront
|
||||
- ./common:/common
|
||||
- ./common:/workspace/common
|
||||
- /workspace/common/node_modules
|
||||
- ./locales:/locales
|
||||
- /app/node_modules
|
||||
- ./locales:/workspace/locales
|
||||
- /workspace/devfront/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
@@ -103,10 +109,13 @@ services:
|
||||
ports:
|
||||
- "${ORGFRONT_PORT:-5175}:5175"
|
||||
volumes:
|
||||
- ./orgfront:/app
|
||||
- ./orgfront:/workspace/orgfront
|
||||
- ./common:/common
|
||||
- ./common:/workspace/common
|
||||
- /workspace/common/node_modules
|
||||
- ./locales:/locales
|
||||
- /app/node_modules
|
||||
- ./locales:/workspace/locales
|
||||
- /workspace/orgfront/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:org-context-chart": "npm run build:org-context-chart:full && npm run build:org-context-chart:min",
|
||||
"build:org-context-chart": "node scripts/build-org-context-chart.mjs",
|
||||
"build:org-context-chart:full": "vite build --config vite.org-context-chart.config.ts",
|
||||
"build:org-context-chart:min": "ORG_CONTEXT_CHART_MINIFY=true vite build --config vite.org-context-chart.config.ts",
|
||||
"lint": "biome check .",
|
||||
|
||||
29
orgfront/scripts/build-org-context-chart.mjs
Normal file
29
orgfront/scripts/build-org-context-chart.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const buildId = createBuildId();
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const env = {
|
||||
...process.env,
|
||||
ORG_CONTEXT_CHART_BUILD_ID: buildId,
|
||||
};
|
||||
|
||||
for (const script of [
|
||||
"build:org-context-chart:full",
|
||||
"build:org-context-chart:min",
|
||||
]) {
|
||||
const result = spawnSync(npmCommand, ["run", script], {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function createBuildId() {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear()).slice(-2);
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
|
||||
return `${year}${month}${random}`;
|
||||
}
|
||||
@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
# If common workspace exists, manage dependencies from there
|
||||
if [ -d /common ] && [ -f /common/package.json ]; then
|
||||
WORKSPACE_DIR="/common"
|
||||
LOCK_FILE="/common/pnpm-lock.yaml"
|
||||
APP_WORKSPACE_FILTER="../orgfront"
|
||||
|
||||
# If common workspace exists, manage dependencies from the real workspace tree.
|
||||
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
|
||||
WORKSPACE_DIR="/workspace/common"
|
||||
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
if [ "$WORKSPACE_DIR" = "/common" ]; then
|
||||
|
||||
(cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
@@ -85,19 +85,101 @@ export type OrgPickerSelection = {
|
||||
type: "tenant" | "user";
|
||||
};
|
||||
|
||||
export type OrgPickerVariant = "default" | "orgfront";
|
||||
|
||||
export type OrgPickerOptions = {
|
||||
mode?: "single" | "multiple";
|
||||
selectable?: "tenant" | "user" | "both";
|
||||
includeDescendants?: boolean;
|
||||
injectStyles?: boolean;
|
||||
showDescendantToggle?: boolean;
|
||||
variant?: OrgPickerVariant;
|
||||
onCancel?: () => void;
|
||||
onChange?: (selection: OrgPickerSelection[]) => void;
|
||||
onConfirm?: (selection: OrgPickerSelection[]) => void;
|
||||
};
|
||||
|
||||
export type OrgPickerController = {
|
||||
cancel: () => void;
|
||||
confirm: () => void;
|
||||
destroy: () => void;
|
||||
getSelection: () => OrgPickerSelection[];
|
||||
};
|
||||
|
||||
const API_PATH = "/api/v1/integrations/org-context";
|
||||
const DEFAULT_STYLE_ID = "baron-org-context-chart-default-style";
|
||||
const DEFAULT_STYLE = `
|
||||
.baron-org-chart,.baron-org-picker{box-sizing:border-box;color:#0f172a;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}
|
||||
.baron-org-chart *,.baron-org-picker *{box-sizing:border-box}
|
||||
.baron-org-chart__tree{display:flex;min-width:100%;gap:24px;overflow:auto;padding:16px}
|
||||
.baron-org-chart__node{min-width:220px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 10px 30px rgba(15,23,42,.08)}
|
||||
.baron-org-chart__title{margin:0;padding:10px 12px;border-bottom:1px solid #e5e7eb;background:#f8fafc;font-size:14px;font-weight:700}
|
||||
.baron-org-chart__meta{margin:0;padding:8px 12px;color:#64748b;font-size:12px}
|
||||
.baron-org-chart__members{margin:0;padding:0 12px 12px 28px;color:#334155;font-size:12px}
|
||||
.baron-org-chart__children{display:flex;gap:16px;margin:12px;padding:12px 0 0 16px;border-left:1px solid #d8dee9}
|
||||
.baron-org-picker{width:100%;max-width:520px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 12px 34px rgba(15,23,42,.1);overflow:hidden}
|
||||
.baron-org-picker__toolbar{display:flex;flex-direction:column;gap:10px;padding:12px;border-bottom:1px solid #e5e7eb;background:#f8fafc}
|
||||
.baron-org-picker__search-wrap{position:relative}
|
||||
.baron-org-picker__search-icon{pointer-events:none;position:absolute;left:12px;top:50%;width:16px;height:16px;transform:translateY(-50%);color:#64748b}
|
||||
.baron-org-picker__search-icon::before{content:"";position:absolute;left:2px;top:2px;width:8px;height:8px;border:2px solid currentColor;border-radius:999px}
|
||||
.baron-org-picker__search-icon::after{content:"";position:absolute;left:10px;top:11px;width:6px;height:2px;background:currentColor;border-radius:999px;transform:rotate(45deg);transform-origin:left center}
|
||||
.baron-org-picker__search{width:100%;height:38px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;padding:0 10px;color:#0f172a;font:inherit;outline:none}
|
||||
.baron-org-picker__search:focus{border-color:#24449c;box-shadow:0 0 0 3px rgba(36,68,156,.18)}
|
||||
.baron-org-picker__controls{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#475569;font-size:12px}
|
||||
.baron-org-picker__descendants{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.baron-org-picker__summary{color:#64748b}
|
||||
.baron-org-picker__clear{border:0;background:transparent;color:#24449c;cursor:pointer;font:inherit;font-weight:700;padding:2px 0}
|
||||
.baron-org-picker__clear:hover{text-decoration:underline}
|
||||
.baron-org-picker__list,.baron-org-picker__children{list-style:none;margin:0;padding:0}
|
||||
.baron-org-picker__list{max-height:420px;overflow:auto;padding:8px}
|
||||
.baron-org-picker__item{margin:0}
|
||||
.baron-org-picker__children{margin-left:8px;border-left:1px solid #e5e7eb}
|
||||
.baron-org-picker__row{display:flex;min-height:32px;align-items:center;gap:8px;border-radius:6px;padding:4px 8px;color:#0f172a;cursor:pointer}
|
||||
.baron-org-picker__row:hover{background:#f1f5f9}
|
||||
.baron-org-picker__row--selected{background:#e8eefc;color:#18327a}
|
||||
.baron-org-picker__row--member{color:#334155;font-size:13px}
|
||||
.baron-org-picker__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.baron-org-picker__label-primary,.baron-org-picker__label-secondary{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.baron-org-picker__label-primary{font-weight:600;line-height:20px}
|
||||
.baron-org-picker__label-secondary{color:#64748b;font-size:12px;line-height:20px}
|
||||
.baron-org-picker input[type="checkbox"],.baron-org-picker input[type="radio"]{accent-color:#24449c}
|
||||
.baron-org-picker__empty{padding:28px 12px;color:#64748b;text-align:center}
|
||||
.baron-org-picker__toggle,.baron-org-picker__toggle-placeholder{display:grid;width:24px;height:24px;flex:0 0 24px;place-items:center;border:0;border-radius:4px;background:transparent;color:#64748b;font:inherit;line-height:1}
|
||||
.baron-org-picker__toggle{cursor:pointer}
|
||||
.baron-org-picker__toggle:hover{background:#e2e8f0}
|
||||
.baron-org-picker__chevron{position:relative;display:block;width:16px;height:16px;color:currentColor}
|
||||
.baron-org-picker__chevron::before{content:"";position:absolute;width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor}
|
||||
.baron-org-picker__chevron--open::before{left:4px;top:3px;transform:rotate(45deg)}
|
||||
.baron-org-picker__chevron--closed::before{left:3px;top:4px;transform:rotate(-45deg)}
|
||||
.baron-org-picker__select{min-width:0;flex:1;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;outline:none;padding:0 4px}
|
||||
.baron-org-picker__select:focus-visible{box-shadow:0 0 0 2px #3a98e5}
|
||||
.baron-org-picker__footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e5e7eb;background:#fff;padding:8px 12px}
|
||||
.baron-org-picker__actions{display:flex;align-items:center;gap:8px}
|
||||
.baron-org-picker__button{display:inline-flex;height:36px;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border-radius:6px;border:1px solid #e5e7eb;background:#fff;color:#0f172a;padding:0 12px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
||||
.baron-org-picker__button:hover{background:#f4b840;color:#0f172a}
|
||||
.baron-org-picker__button--primary{border-color:#3a98e5;background:#3a98e5;color:#fff;box-shadow:0 1px 2px rgba(15,23,42,.12)}
|
||||
.baron-org-picker__button--primary:hover{background:#2588d8;color:#fff}
|
||||
.baron-org-picker__button:disabled{cursor:not-allowed;opacity:.5}
|
||||
.baron-org-picker--orgfront{--boc-background:hsl(var(--background,0 0% 98%));--boc-foreground:hsl(var(--foreground,223 25% 12%));--boc-primary:hsl(var(--primary,209 79% 52%));--boc-secondary:hsl(var(--secondary,220 17% 94%));--boc-muted-foreground:hsl(var(--muted-foreground,223 15% 45%));--boc-accent:hsl(var(--accent,40 96% 62%));--boc-accent-foreground:hsl(var(--accent-foreground,223 25% 12%));--boc-border:hsl(var(--border,220 17% 90%));--boc-input:hsl(var(--input,220 17% 90%));--boc-ring:hsl(var(--ring,209 79% 52%));display:flex;height:100%;min-height:320px;max-width:none;flex-direction:column;border:0;border-radius:0;background:var(--boc-background);box-shadow:none;color:var(--boc-foreground);overflow:hidden}
|
||||
.baron-org-picker--orgfront .baron-org-picker__toolbar{display:block;border-bottom:1px solid var(--boc-border);background:var(--boc-background);padding:8px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__toolbar-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__search{height:36px;border-color:var(--boc-input);border-radius:6px;background:var(--boc-background);padding:0 12px 0 36px;font-size:14px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__search:focus{border-color:var(--boc-input);box-shadow:0 0 0 2px var(--boc-ring)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__controls{height:36px;font-size:14px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__descendants{height:36px;font-size:14px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__list{min-height:0;flex:1;max-height:none;overflow:auto;padding:12px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__children{margin-left:16px;border-left:0}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row{min-height:28px;gap:6px;border-radius:4px;padding:2px 6px 2px 4px;transition:background-color .15s,color .15s,box-shadow .15s}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row:hover{background:color-mix(in srgb,var(--boc-secondary) 50%,transparent)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row--selected{background:color-mix(in srgb,var(--boc-primary) 15%,transparent);box-shadow:0 0 0 2px color-mix(in srgb,var(--boc-primary) 60%,transparent);color:var(--boc-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row--member{font-size:14px;color:var(--boc-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__footer{border-top-color:var(--boc-border);background:var(--boc-background)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__summary{font-size:14px;color:var(--boc-muted-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__button{border-color:var(--boc-input);background:var(--boc-background);color:var(--boc-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__button:hover{background:var(--boc-accent);color:var(--boc-accent-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__button--primary{border-color:var(--boc-primary);background:var(--boc-primary);color:#fff}
|
||||
.baron-org-picker--orgfront .baron-org-picker__empty{margin:12px;min-height:160px;border:1px dashed var(--boc-border);border-radius:6px;background:var(--boc-background);display:grid;place-items:center}
|
||||
`;
|
||||
|
||||
export function createOrgContextClient(options: OrgContextClientOptions) {
|
||||
const fetcher = options.fetch ?? globalThis.fetch;
|
||||
@@ -202,6 +284,7 @@ export function renderOrgChart(
|
||||
container: HTMLElement,
|
||||
model: OrgChartModel,
|
||||
): { destroy: () => void } {
|
||||
ensureDefaultStyles();
|
||||
container.replaceChildren();
|
||||
container.classList.add("baron-org-chart");
|
||||
const root = document.createElement("div");
|
||||
@@ -221,13 +304,24 @@ export function renderOrgPicker(
|
||||
model: OrgChartModel,
|
||||
options: OrgPickerOptions = {},
|
||||
): OrgPickerController {
|
||||
if (options.injectStyles !== false) {
|
||||
ensureDefaultStyles();
|
||||
}
|
||||
const mode = options.mode ?? "single";
|
||||
const selectable = options.selectable ?? "tenant";
|
||||
const includeDescendants = options.includeDescendants ?? false;
|
||||
const variant = options.variant ?? "default";
|
||||
const isOrgfront = variant === "orgfront";
|
||||
let includeDescendants =
|
||||
options.includeDescendants ?? (isOrgfront && mode === "multiple");
|
||||
let searchQuery = "";
|
||||
const selected = new Map<string, OrgPickerSelection>();
|
||||
const expanded = new Set(model.nodes.map((node) => node.id));
|
||||
const showDescendantToggle = options.showDescendantToggle ?? true;
|
||||
|
||||
const currentSelection = () => Array.from(selected.values());
|
||||
|
||||
const emitChange = () => {
|
||||
const selection = Array.from(selected.values());
|
||||
const selection = currentSelection();
|
||||
options.onChange?.(selection);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("baron-org-picker-change", {
|
||||
@@ -237,6 +331,26 @@ export function renderOrgPicker(
|
||||
);
|
||||
};
|
||||
|
||||
const emitConfirm = () => {
|
||||
const selection = currentSelection();
|
||||
options.onConfirm?.(selection);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("baron-org-picker-confirm", {
|
||||
bubbles: true,
|
||||
detail: { selection },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const emitCancel = () => {
|
||||
options.onCancel?.();
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("baron-org-picker-cancel", {
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelection = (
|
||||
selection: OrgPickerSelection,
|
||||
checked: boolean,
|
||||
@@ -270,17 +384,82 @@ export function renderOrgPicker(
|
||||
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "baron-org-picker__item";
|
||||
|
||||
const row = document.createElement("label");
|
||||
row.className = "baron-org-picker__row";
|
||||
row.style.paddingLeft = `${node.depth * 16}px`;
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
const tenantSelection: OrgPickerSelection = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: "tenant",
|
||||
};
|
||||
if (selectable === "tenant" || selectable === "both") {
|
||||
const row = document.createElement(isOrgfront ? "div" : "label");
|
||||
row.className = "baron-org-picker__row";
|
||||
if (selected.has(selectionKey(tenantSelection))) {
|
||||
row.classList.add("baron-org-picker__row--selected");
|
||||
}
|
||||
row.style.paddingLeft = `${node.depth * 16}px`;
|
||||
|
||||
if (isOrgfront) {
|
||||
row.append(createExpandToggle(node, hasChildren));
|
||||
appendOrgfrontSelectionControl(row, node, tenantSelection);
|
||||
} else {
|
||||
if (selectable === "tenant" || selectable === "both") {
|
||||
row.append(
|
||||
createPickerInput({
|
||||
mode,
|
||||
selection: tenantSelection,
|
||||
selected,
|
||||
onToggle: (checked) =>
|
||||
toggleSelection(
|
||||
tenantSelection,
|
||||
checked,
|
||||
collectDescendantSelections(node, selectable),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
row.append(createLabelText(node.name, node.type));
|
||||
}
|
||||
item.append(row);
|
||||
|
||||
if (selectable === "user" || selectable === "both") {
|
||||
for (const member of node.members) {
|
||||
item.append(
|
||||
renderMemberPickerRow(
|
||||
member,
|
||||
node,
|
||||
mode,
|
||||
selected,
|
||||
(value) =>
|
||||
toggleSelection(value, !selected.has(selectionKey(value)), []),
|
||||
isOrgfront,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChildren && (!isOrgfront || expanded.has(node.id))) {
|
||||
const children = document.createElement("ul");
|
||||
children.className = "baron-org-picker__children";
|
||||
for (const child of node.children) {
|
||||
children.append(renderPickerNode(child));
|
||||
}
|
||||
item.append(children);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const appendOrgfrontSelectionControl = (
|
||||
row: HTMLElement,
|
||||
node: OrgChartNode,
|
||||
tenantSelection: OrgPickerSelection,
|
||||
) => {
|
||||
const canSelect = selectable === "tenant" || selectable === "both";
|
||||
if (!canSelect) {
|
||||
row.append(createOrgfrontLabelText(node.name));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "multiple") {
|
||||
row.append(
|
||||
createPickerInput({
|
||||
mode,
|
||||
@@ -294,50 +473,231 @@ export function renderOrgPicker(
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
row.append(createLabelText(node.name, node.type));
|
||||
item.append(row);
|
||||
|
||||
if (selectable === "user" || selectable === "both") {
|
||||
for (const member of node.members) {
|
||||
item.append(
|
||||
renderMemberPickerRow(member, node, mode, selected, (value) =>
|
||||
toggleSelection(value, !selected.has(selectionKey(value)), []),
|
||||
),
|
||||
);
|
||||
}
|
||||
row.append(createOrgfrontLabelText(node.name));
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
const children = document.createElement("ul");
|
||||
children.className = "baron-org-picker__children";
|
||||
for (const child of node.children) {
|
||||
children.append(renderPickerNode(child));
|
||||
}
|
||||
item.append(children);
|
||||
const button = document.createElement("button");
|
||||
button.className = "baron-org-picker__select";
|
||||
button.dataset.baronOrgPickerValue = selectionKey(tenantSelection);
|
||||
button.type = "button";
|
||||
button.ariaPressed = String(selected.has(selectionKey(tenantSelection)));
|
||||
button.addEventListener("click", () =>
|
||||
toggleSelection(
|
||||
tenantSelection,
|
||||
true,
|
||||
collectDescendantSelections(node, selectable),
|
||||
),
|
||||
);
|
||||
button.append(createOrgfrontLabelText(node.name));
|
||||
row.append(button);
|
||||
};
|
||||
|
||||
const createExpandToggle = (node: OrgChartNode, hasChildren: boolean) => {
|
||||
if (!hasChildren) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "baron-org-picker__toggle-placeholder";
|
||||
placeholder.ariaHidden = "true";
|
||||
return placeholder;
|
||||
}
|
||||
return item;
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.className = "baron-org-picker__toggle";
|
||||
toggle.dataset.baronOrgPickerToggle = selectionKey({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: "tenant",
|
||||
});
|
||||
toggle.type = "button";
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = expanded.has(node.id)
|
||||
? "baron-org-picker__chevron baron-org-picker__chevron--open"
|
||||
: "baron-org-picker__chevron baron-org-picker__chevron--closed";
|
||||
chevron.dataset.baronOrgPickerChevron = "true";
|
||||
chevron.ariaHidden = "true";
|
||||
toggle.append(chevron);
|
||||
toggle.ariaLabel = `${node.name} ${expanded.has(node.id) ? "접기" : "펼치기"}`;
|
||||
toggle.addEventListener("click", () => {
|
||||
if (expanded.has(node.id)) {
|
||||
expanded.delete(node.id);
|
||||
} else {
|
||||
expanded.add(node.id);
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
return toggle;
|
||||
};
|
||||
|
||||
const rerender = () => {
|
||||
container.replaceChildren();
|
||||
container.classList.add("baron-org-picker");
|
||||
container.classList.toggle("baron-org-picker--orgfront", isOrgfront);
|
||||
container.append(renderPickerToolbar());
|
||||
|
||||
const visibleRoot = filterOrgChartNode(model.root, searchQuery, selectable);
|
||||
if (!visibleRoot) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "baron-org-picker__empty";
|
||||
empty.textContent = isOrgfront
|
||||
? "검색 결과가 없습니다."
|
||||
: "No matching organization or member.";
|
||||
container.append(empty);
|
||||
if (isOrgfront) {
|
||||
container.append(renderPickerFooter());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "baron-org-picker__list";
|
||||
list.append(renderPickerNode(model.root));
|
||||
list.append(renderPickerNode(visibleRoot));
|
||||
container.append(list);
|
||||
if (isOrgfront) {
|
||||
container.append(renderPickerFooter());
|
||||
}
|
||||
};
|
||||
|
||||
const renderPickerToolbar = () => {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "baron-org-picker__toolbar";
|
||||
const toolbarContent = isOrgfront ? document.createElement("div") : toolbar;
|
||||
if (isOrgfront) {
|
||||
toolbarContent.className = "baron-org-picker__toolbar-grid";
|
||||
toolbar.append(toolbarContent);
|
||||
}
|
||||
|
||||
const searchWrap = document.createElement("div");
|
||||
searchWrap.className = "baron-org-picker__search-wrap";
|
||||
if (isOrgfront) {
|
||||
const searchIcon = document.createElement("span");
|
||||
searchIcon.className = "baron-org-picker__search-icon";
|
||||
searchIcon.dataset.baronOrgPickerSearchIcon = "true";
|
||||
searchIcon.ariaHidden = "true";
|
||||
searchWrap.append(searchIcon);
|
||||
}
|
||||
const search = document.createElement("input");
|
||||
search.className = "baron-org-picker__search";
|
||||
search.dataset.baronOrgPickerSearch = "true";
|
||||
search.placeholder = isOrgfront
|
||||
? "ID, 이름, 이메일, 메타데이터"
|
||||
: "Search organization or member";
|
||||
search.type = "search";
|
||||
search.value = searchQuery;
|
||||
search.addEventListener("input", () => {
|
||||
searchQuery = search.value;
|
||||
rerender();
|
||||
const nextSearch = container.querySelector<HTMLInputElement>(
|
||||
"[data-baron-org-picker-search]",
|
||||
);
|
||||
nextSearch?.focus();
|
||||
nextSearch?.setSelectionRange(searchQuery.length, searchQuery.length);
|
||||
});
|
||||
searchWrap.append(search);
|
||||
toolbarContent.append(searchWrap);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "baron-org-picker__controls";
|
||||
if (mode === "multiple" && selectable !== "user" && showDescendantToggle) {
|
||||
const descendantsLabel = document.createElement("label");
|
||||
descendantsLabel.className = "baron-org-picker__descendants";
|
||||
const descendants = document.createElement("input");
|
||||
descendants.dataset.baronOrgPickerDescendants = "true";
|
||||
descendants.type = "checkbox";
|
||||
descendants.checked = includeDescendants;
|
||||
const updateDescendantSelection = () => {
|
||||
includeDescendants = descendants.checked;
|
||||
rerender();
|
||||
};
|
||||
descendants.addEventListener("change", updateDescendantSelection);
|
||||
descendants.addEventListener("click", updateDescendantSelection);
|
||||
descendantsLabel.append(
|
||||
descendants,
|
||||
isOrgfront ? "하위 선택" : "Include descendants",
|
||||
);
|
||||
controls.append(descendantsLabel);
|
||||
} else if (!isOrgfront) {
|
||||
controls.append(document.createElement("span"));
|
||||
}
|
||||
|
||||
if (!isOrgfront) {
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "baron-org-picker__summary";
|
||||
summary.dataset.baronOrgPickerSummary = "true";
|
||||
summary.textContent = `${selected.size} selected`;
|
||||
controls.append(summary);
|
||||
|
||||
if (selected.size > 0) {
|
||||
const clear = document.createElement("button");
|
||||
clear.className = "baron-org-picker__clear";
|
||||
clear.type = "button";
|
||||
clear.textContent = "Clear";
|
||||
clear.addEventListener("click", () => {
|
||||
selected.clear();
|
||||
emitChange();
|
||||
rerender();
|
||||
});
|
||||
controls.append(clear);
|
||||
}
|
||||
}
|
||||
|
||||
toolbarContent.append(controls);
|
||||
return toolbar;
|
||||
};
|
||||
|
||||
const renderPickerFooter = () => {
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "baron-org-picker__footer";
|
||||
footer.dataset.baronOrgPickerFooter = "true";
|
||||
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "baron-org-picker__summary";
|
||||
summary.dataset.baronOrgPickerSummary = "true";
|
||||
summary.textContent =
|
||||
selected.size > 0
|
||||
? `${selected.size}개 항목 선택됨`
|
||||
: "선택된 항목이 없습니다.";
|
||||
footer.append(summary);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "baron-org-picker__actions";
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = "baron-org-picker__button";
|
||||
cancel.dataset.baronOrgPickerCancel = "true";
|
||||
cancel.type = "button";
|
||||
cancel.textContent = "취소";
|
||||
cancel.addEventListener("click", emitCancel);
|
||||
actions.append(cancel);
|
||||
|
||||
const confirm = document.createElement("button");
|
||||
confirm.className =
|
||||
"baron-org-picker__button baron-org-picker__button--primary";
|
||||
confirm.dataset.baronOrgPickerConfirm = "true";
|
||||
confirm.disabled = selected.size === 0;
|
||||
confirm.type = "button";
|
||||
confirm.textContent = "선택 완료";
|
||||
confirm.addEventListener("click", emitConfirm);
|
||||
actions.append(confirm);
|
||||
|
||||
footer.append(actions);
|
||||
return footer;
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
return {
|
||||
cancel: emitCancel,
|
||||
confirm: emitConfirm,
|
||||
destroy() {
|
||||
container.replaceChildren();
|
||||
container.classList.remove("baron-org-picker");
|
||||
container.classList.remove(
|
||||
"baron-org-picker",
|
||||
"baron-org-picker--orgfront",
|
||||
);
|
||||
selected.clear();
|
||||
},
|
||||
getSelection() {
|
||||
return Array.from(selected.values());
|
||||
return currentSelection();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -356,6 +716,16 @@ function normalizeBaseUrl(baseUrl: string) {
|
||||
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
|
||||
function ensureDefaultStyles() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById(DEFAULT_STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = DEFAULT_STYLE_ID;
|
||||
style.dataset.baronOrgContextChartStyle = "default";
|
||||
style.textContent = DEFAULT_STYLE;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function renderChartNode(node: OrgChartNode): HTMLElement {
|
||||
const item = document.createElement("section");
|
||||
item.className = "baron-org-chart__node";
|
||||
@@ -401,15 +771,35 @@ function renderMemberPickerRow(
|
||||
mode: "single" | "multiple",
|
||||
selected: Map<string, OrgPickerSelection>,
|
||||
onSelect: (selection: OrgPickerSelection) => void,
|
||||
isOrgfront = false,
|
||||
) {
|
||||
const selection: OrgPickerSelection = {
|
||||
id: member.id || `${node.id}:${member.email}`,
|
||||
name: member.name,
|
||||
type: "user",
|
||||
};
|
||||
const row = document.createElement("label");
|
||||
const row = document.createElement(isOrgfront ? "div" : "label");
|
||||
row.className = "baron-org-picker__row baron-org-picker__row--member";
|
||||
if (selected.has(selectionKey(selection))) {
|
||||
row.classList.add("baron-org-picker__row--selected");
|
||||
}
|
||||
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
|
||||
if (isOrgfront && mode === "single") {
|
||||
row.append(createOrgfrontMemberSpacer());
|
||||
const button = document.createElement("button");
|
||||
button.className = "baron-org-picker__select";
|
||||
button.dataset.baronOrgPickerValue = selectionKey(selection);
|
||||
button.type = "button";
|
||||
button.ariaPressed = String(selected.has(selectionKey(selection)));
|
||||
button.addEventListener("click", () => onSelect(selection));
|
||||
button.append(createOrgfrontLabelText(member.name, member.email));
|
||||
row.append(button);
|
||||
return row;
|
||||
}
|
||||
|
||||
if (isOrgfront) {
|
||||
row.append(createOrgfrontMemberSpacer());
|
||||
}
|
||||
row.append(
|
||||
createPickerInput({
|
||||
mode,
|
||||
@@ -418,7 +808,11 @@ function renderMemberPickerRow(
|
||||
onToggle: () => onSelect(selection),
|
||||
}),
|
||||
);
|
||||
row.append(createLabelText(member.name, member.email));
|
||||
row.append(
|
||||
isOrgfront
|
||||
? createOrgfrontLabelText(member.name, member.email)
|
||||
: createLabelText(member.name, member.email),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -452,6 +846,103 @@ function createLabelText(primary: string, secondary?: string) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function createOrgfrontMemberSpacer() {
|
||||
const spacer = document.createElement("span");
|
||||
spacer.className = "baron-org-picker__toggle-placeholder";
|
||||
spacer.ariaHidden = "true";
|
||||
return spacer;
|
||||
}
|
||||
|
||||
function createOrgfrontLabelText(primary: string, secondary?: string) {
|
||||
const text = document.createElement("span");
|
||||
text.className = "baron-org-picker__label";
|
||||
const primaryText = document.createElement("span");
|
||||
primaryText.className = "baron-org-picker__label-primary";
|
||||
primaryText.textContent = primary;
|
||||
text.append(primaryText);
|
||||
if (secondary) {
|
||||
const secondaryText = document.createElement("span");
|
||||
secondaryText.className = "baron-org-picker__label-secondary";
|
||||
secondaryText.textContent = secondary;
|
||||
text.append(secondaryText);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function filterOrgChartNode(
|
||||
node: OrgChartNode,
|
||||
rawQuery: string,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
): OrgChartNode | null {
|
||||
const query = rawQuery.trim().toLowerCase();
|
||||
if (!query) return node;
|
||||
|
||||
const childMatches = node.children
|
||||
.map((child) => filterOrgChartNode(child, rawQuery, selectable))
|
||||
.filter((child): child is OrgChartNode => Boolean(child));
|
||||
const tenantMatch = orgTenantMatchesSearch(node, query, selectable);
|
||||
const matchingMembers = orgMemberMatchesSearch(node, query, selectable);
|
||||
if (
|
||||
!tenantMatch &&
|
||||
matchingMembers.length === 0 &&
|
||||
childMatches.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
members: tenantMatch ? node.members : matchingMembers,
|
||||
children: childMatches,
|
||||
};
|
||||
}
|
||||
|
||||
function orgTenantMatchesSearch(
|
||||
node: OrgChartNode,
|
||||
query: string,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
) {
|
||||
const tenantValues = [
|
||||
node.id,
|
||||
node.name,
|
||||
node.slug,
|
||||
node.type,
|
||||
node.orgUnitType ?? "",
|
||||
node.visibility,
|
||||
...node.domains,
|
||||
];
|
||||
if (
|
||||
selectable !== "user" &&
|
||||
tenantValues.some((value) => value.toLowerCase().includes(query))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (selectable === "tenant") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function orgMemberMatchesSearch(
|
||||
node: OrgChartNode,
|
||||
query: string,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
) {
|
||||
if (selectable === "tenant") {
|
||||
return [];
|
||||
}
|
||||
return node.members.filter((member) =>
|
||||
[
|
||||
member.id ?? "",
|
||||
member.email,
|
||||
member.name,
|
||||
member.department ?? "",
|
||||
member.grade ?? "",
|
||||
member.position ?? "",
|
||||
member.jobTitle ?? "",
|
||||
].some((value) => value.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectDescendantSelections(
|
||||
node: OrgChartNode,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
|
||||
@@ -168,4 +168,179 @@ describe("org-context chart SDK", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("packages default picker UX and styles with search and descendant selection", () => {
|
||||
const model = buildOrgChartModel(sampleOrgContext);
|
||||
const pickerContainer = document.createElement("div");
|
||||
const onChange = vi.fn();
|
||||
|
||||
const picker = renderOrgPicker(pickerContainer, model, {
|
||||
mode: "multiple",
|
||||
selectable: "both",
|
||||
onChange,
|
||||
});
|
||||
|
||||
expect(
|
||||
document.head.querySelector(
|
||||
'style[data-baron-org-context-chart-style="default"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[type="search"][data-baron-org-picker-search]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"][data-baron-org-picker-descendants]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
pickerContainer.querySelector("[data-baron-org-picker-summary]")
|
||||
?.textContent,
|
||||
).toContain("0 selected");
|
||||
|
||||
const search = pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[type="search"][data-baron-org-picker-search]',
|
||||
);
|
||||
expect(search).not.toBeNull();
|
||||
if (!search) return;
|
||||
search.value = "platform";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
expect(pickerContainer.textContent).toContain("Platform");
|
||||
expect(pickerContainer.textContent).not.toContain(
|
||||
"Leader (leader@example.com)",
|
||||
);
|
||||
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
const descendantToggle = pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"][data-baron-org-picker-descendants]',
|
||||
);
|
||||
expect(descendantToggle).not.toBeNull();
|
||||
descendantToggle?.click();
|
||||
const companyBaron = pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[value="tenant:company-baron"]',
|
||||
);
|
||||
expect(companyBaron).not.toBeNull();
|
||||
companyBaron?.click();
|
||||
|
||||
expect(picker.getSelection()).toEqual([
|
||||
{ id: "company-baron", name: "Baron", type: "tenant" },
|
||||
{ id: "team-platform", name: "Platform", type: "tenant" },
|
||||
{
|
||||
id: "team-platform:engineer@example.com",
|
||||
name: "Engineer",
|
||||
type: "user",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
pickerContainer.querySelector("[data-baron-org-picker-summary]")
|
||||
?.textContent,
|
||||
).toContain("3 selected");
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{ id: "company-baron", name: "Baron", type: "tenant" },
|
||||
{ id: "team-platform", name: "Platform", type: "tenant" },
|
||||
{
|
||||
id: "team-platform:engineer@example.com",
|
||||
name: "Engineer",
|
||||
type: "user",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the orgfront-compatible picker UX", () => {
|
||||
const model = buildOrgChartModel(sampleOrgContext);
|
||||
const pickerContainer = document.createElement("div");
|
||||
const onChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const picker = renderOrgPicker(pickerContainer, model, {
|
||||
mode: "single",
|
||||
selectable: "tenant",
|
||||
variant: "orgfront",
|
||||
showDescendantToggle: false,
|
||||
onCancel,
|
||||
onChange,
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
expect(
|
||||
pickerContainer.classList.contains("baron-org-picker--orgfront"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[type="radio"][value="tenant:company-baron"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
pickerContainer.querySelector("[data-baron-org-picker-search-icon]"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
pickerContainer.querySelector<HTMLInputElement>(
|
||||
"[data-baron-org-picker-search]",
|
||||
)?.placeholder,
|
||||
).toBe("ID, 이름, 이메일, 메타데이터");
|
||||
expect(
|
||||
pickerContainer.querySelector("[data-baron-org-picker-descendants]"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
pickerContainer.querySelector("[data-baron-org-picker-footer]"),
|
||||
).not.toBeNull();
|
||||
|
||||
const companyButton = pickerContainer.querySelector<HTMLButtonElement>(
|
||||
'button[data-baron-org-picker-value="tenant:company-baron"]',
|
||||
);
|
||||
expect(companyButton).not.toBeNull();
|
||||
companyButton?.click();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: "company-baron", name: "Baron", type: "tenant" },
|
||||
]);
|
||||
expect(picker.getSelection()).toEqual([
|
||||
{ id: "company-baron", name: "Baron", type: "tenant" },
|
||||
]);
|
||||
|
||||
const collapse = pickerContainer.querySelector<HTMLButtonElement>(
|
||||
'button[data-baron-org-picker-toggle="tenant:company-baron"]',
|
||||
);
|
||||
expect(collapse).not.toBeNull();
|
||||
expect(collapse?.textContent).toBe("");
|
||||
expect(
|
||||
collapse?.querySelector("[data-baron-org-picker-chevron]"),
|
||||
).not.toBeNull();
|
||||
collapse?.click();
|
||||
const collapsed = pickerContainer.querySelector<HTMLButtonElement>(
|
||||
'button[data-baron-org-picker-toggle="tenant:company-baron"]',
|
||||
);
|
||||
expect(
|
||||
collapsed
|
||||
?.querySelector("[data-baron-org-picker-chevron]")
|
||||
?.classList.contains("baron-org-picker__chevron--open"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
pickerContainer.querySelector(
|
||||
'button[data-baron-org-picker-value="tenant:team-platform"]',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const confirm = pickerContainer.querySelector<HTMLButtonElement>(
|
||||
"[data-baron-org-picker-confirm]",
|
||||
);
|
||||
expect(confirm?.disabled).toBe(false);
|
||||
confirm?.click();
|
||||
expect(onConfirm).toHaveBeenCalledWith([
|
||||
{ id: "company-baron", name: "Baron", type: "tenant" },
|
||||
]);
|
||||
|
||||
pickerContainer
|
||||
.querySelector<HTMLButtonElement>("[data-baron-org-picker-cancel]")
|
||||
?.click();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ const config: Config = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
"../common/**/*.{ts,tsx,css}",
|
||||
"../common/core/**/*.{ts,tsx}",
|
||||
"../common/shell/**/*.{ts,tsx}",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,17 @@ import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const isMinifiedBuild = process.env.ORG_CONTEXT_CHART_MINIFY === "true";
|
||||
const buildId = process.env.ORG_CONTEXT_CHART_BUILD_ID ?? createBuildId();
|
||||
const fileSuffix = isMinifiedBuild ? ".min" : "";
|
||||
const fileBaseName = `boc-${buildId}${fileSuffix}`;
|
||||
|
||||
function createBuildId() {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear()).slice(-2);
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
|
||||
return `${year}${month}${random}`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -12,9 +22,7 @@ export default defineConfig({
|
||||
new URL("./src/sdk/org-context-chart/index.ts", import.meta.url),
|
||||
),
|
||||
fileName: (format) =>
|
||||
format === "es"
|
||||
? `baron-org-context-chart${fileSuffix}.js`
|
||||
: `baron-org-context-chart${fileSuffix}.umd.cjs`,
|
||||
format === "es" ? `${fileBaseName}.js` : `${fileBaseName}.umd.cjs`,
|
||||
formats: ["es", "umd"],
|
||||
name: "BaronOrgContextChart",
|
||||
},
|
||||
|
||||
50
test/adminfront_dev_performance_policy_test.sh
Normal file
50
test/adminfront_dev_performance_policy_test.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
grep -Fq -- "$pattern" "$file" || fail "$file must contain: $pattern"
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
if grep -Fq -- "$pattern" "$file"; then
|
||||
fail "$file must not contain: $pattern"
|
||||
fi
|
||||
}
|
||||
|
||||
for config in \
|
||||
"$ROOT_DIR/adminfront/tailwind.config.ts" \
|
||||
"$ROOT_DIR/devfront/tailwind.config.ts" \
|
||||
"$ROOT_DIR/orgfront/tailwind.config.ts"
|
||||
do
|
||||
assert_not_contains "$config" "../common/**/*.{ts,tsx,css}"
|
||||
assert_contains "$config" "../common/core/**/*.{ts,tsx}"
|
||||
assert_contains "$config" "../common/shell/**/*.{ts,tsx}"
|
||||
done
|
||||
|
||||
assert_contains "$ROOT_DIR/common/config/vite.base.ts" "/workspace/common"
|
||||
|
||||
assert_not_contains \
|
||||
"$ROOT_DIR/adminfront/src/features/tenants/routes/TenantDetailPage.tsx" \
|
||||
"export function canShowWorksmobileEntry"
|
||||
assert_not_contains \
|
||||
"$ROOT_DIR/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx" \
|
||||
"export function createSchemaField"
|
||||
assert_not_contains \
|
||||
"$ROOT_DIR/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx" \
|
||||
"export function buildWorksmobilePasswordManageUrl"
|
||||
assert_not_contains \
|
||||
"$ROOT_DIR/adminfront/src/features/tenants/components/ParentTenantSelector.tsx" \
|
||||
"export function filterParentTenants"
|
||||
|
||||
echo "OK: adminfront dev performance settings avoid wide scans and route HMR invalidation"
|
||||
47
test/frontend_dev_bind_mount_policy_test.sh
Normal file
47
test/frontend_dev_bind_mount_policy_test.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yaml"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local pattern="$1"
|
||||
grep -Fq -- "$pattern" "$COMPOSE_FILE" || fail "docker-compose.yaml must contain: $pattern"
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local pattern="$1"
|
||||
if grep -Fq -- "$pattern" "$COMPOSE_FILE"; then
|
||||
fail "docker-compose.yaml must not contain stale frontend mount: $pattern"
|
||||
fi
|
||||
}
|
||||
|
||||
for app in adminfront devfront orgfront; do
|
||||
assert_contains "./$app:/workspace/$app"
|
||||
assert_contains "/workspace/$app/node_modules"
|
||||
assert_not_contains "./$app:/app"
|
||||
done
|
||||
|
||||
assert_contains "./common:/workspace/common"
|
||||
assert_contains "/workspace/common/node_modules"
|
||||
assert_contains "./locales:/workspace/locales"
|
||||
|
||||
for runtime in \
|
||||
"$ROOT_DIR/adminfront/scripts/runtime-mode.sh" \
|
||||
"$ROOT_DIR/devfront/scripts/runtime-mode.sh" \
|
||||
"$ROOT_DIR/orgfront/scripts/runtime-mode.sh"
|
||||
do
|
||||
grep -Fq -- "/workspace/common" "$runtime" || fail "$runtime must install dependencies from /workspace/common"
|
||||
grep -Fq -- "pnpm install --filter" "$runtime" || fail "$runtime must install only its workspace slice"
|
||||
grep -Fq -- "--frozen-lockfile --ignore-scripts" "$runtime" || fail "$runtime must preserve the workspace lockfile with pnpm"
|
||||
if grep -Fq -- "npm install --no-workspaces" "$runtime"; then
|
||||
fail "$runtime must not install common dependencies outside the workspace graph"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "OK: frontend dev containers bind-mount source into Dockerfile WORKDIR paths"
|
||||
@@ -50,8 +50,8 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
assert_contains "$LOCAL_COMPOSE" "context: ./orgfront"
|
||||
assert_contains "$LOCAL_COMPOSE" "./orgfront:/app"
|
||||
assert_contains "$LOCAL_COMPOSE" "dockerfile: ./orgfront/Dockerfile"
|
||||
assert_contains "$LOCAL_COMPOSE" "./orgfront:/workspace/orgfront"
|
||||
assert_not_contains "$LOCAL_COMPOSE" "../baron-orgchart"
|
||||
|
||||
for file in "$STAGING_COMPOSE" "$PULL_COMPOSE" "$DEPLOY_TEMPLATE"; do
|
||||
@@ -74,9 +74,9 @@ assert_contains "$BUILD_RC" "context: ./orgfront"
|
||||
assert_contains "$BUILD_RC" "/baron_sso/orgfront:"
|
||||
|
||||
assert_contains "$CODE_CHECK" "run_orgfront_tests"
|
||||
assert_contains "$CODE_CHECK" "orgfront/package-lock.json"
|
||||
assert_contains "$CODE_CHECK" "cd orgfront"
|
||||
assert_contains "$CODE_CHECK" "npm test"
|
||||
assert_contains "$CODE_CHECK" "pnpm install -C ../common --no-frozen-lockfile"
|
||||
assert_contains "$CODE_CHECK" "pnpm run test"
|
||||
|
||||
assert_contains "$STAGING_RELEASE" "ORGFRONT_IMAGE_NAME"
|
||||
assert_contains "$STAGING_RELEASE" "ORGFRONT_PORT="
|
||||
|
||||
@@ -16,7 +16,17 @@ assert_contains() {
|
||||
}
|
||||
|
||||
assert_contains orgfront/package.json "build:org-context-chart:min"
|
||||
assert_contains orgfront/package.json "scripts/build-org-context-chart.mjs"
|
||||
assert_contains orgfront/scripts/build-org-context-chart.mjs "ORG_CONTEXT_CHART_BUILD_ID"
|
||||
assert_contains orgfront/vite.org-context-chart.config.ts "ORG_CONTEXT_CHART_MINIFY"
|
||||
assert_contains orgfront/vite.org-context-chart.config.ts "ORG_CONTEXT_CHART_BUILD_ID"
|
||||
assert_contains orgfront/vite.org-context-chart.config.ts "boc-"
|
||||
assert_contains orgfront/vite.org-context-chart.config.ts ".min"
|
||||
assert_contains orgfront/scripts/build-org-context-chart.mjs 'return `${year}${month}${random}`;'
|
||||
assert_contains orgfront/vite.org-context-chart.config.ts 'return `${year}${month}${random}`;'
|
||||
|
||||
echo "OK: OrgContext chart package emits explicit minified bundles"
|
||||
if grep -Fq '${year}${month}${day}' orgfront/scripts/build-org-context-chart.mjs orgfront/vite.org-context-chart.config.ts; then
|
||||
fail "OrgContext chart build id must use YYMM plus 4 random digits, without day or separators"
|
||||
fi
|
||||
|
||||
echo "OK: OrgContext chart package emits timestamped short bundle names"
|
||||
|
||||
30
test/shell_layout_policy_test.sh
Normal file
30
test/shell_layout_policy_test.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LAYOUT_FILE="$ROOT_DIR/common/shell/layout.ts"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local pattern="$1"
|
||||
grep -Fq -- "$pattern" "$LAYOUT_FILE" || fail "common shell layout must contain: $pattern"
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local pattern="$1"
|
||||
if grep -Fq -- "$pattern" "$LAYOUT_FILE"; then
|
||||
fail "common shell layout must not contain: $pattern"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains "root: \"grid min-h-screen grid-cols-[240px,minmax(0,1fr)]"
|
||||
assert_not_contains "md:grid-cols-[240px,1fr]"
|
||||
assert_contains "aside:"
|
||||
assert_contains "sticky top-0 h-screen"
|
||||
assert_not_contains "border-b border-border bg-card md:sticky"
|
||||
|
||||
echo "OK: shell layout keeps the navigation in a fixed left column"
|
||||
Reference in New Issue
Block a user