1
0
forked from baron/baron-sso

네이버 웍스 연동기능 개선

This commit is contained in:
2026-05-18 15:36:30 +09:00
parent c71ece84b8
commit e29d056b9e
61 changed files with 4137 additions and 710 deletions

View File

@@ -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"
1 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
2 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
3 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

View 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)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 (주)장헌 0 jangheon@brsw.kr Y N Y Y
3 임원실 0 t_617rl@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
4 생산부 0 t_921uz@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
5 공무팀 0 t_733vx@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
6 철근팀 0 t_334vk@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
7 제작1팀 0 t_196vt@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
8 제작2팀 0 t_690ka@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
9 품질팀 0 t_013sr@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
10 업무지원팀 0 t_601un@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
11 PTC 0 ptc@brsw.kr Y N Y Y
12 임원실 0 t_771pf@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
13 영업팀 0 t_375vv@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
14 사업관리팀 0 t_054fx@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
15 시공팀 0 t_871dc@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
16 설계팀 0 t_156ss@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
17 네이버웍스관리용(바론그룹) 2 슈퍼관리자(su-@samaneng.com) su4@brsw.kr N N N Y
18 장헌산업 0 jangheon-sanup@brsw.kr Y N Y Y
19 임원실 0 t_049ij@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
20 경영지원부 0 t_166wx@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
21 기술영업본부 0 t_444be@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
22 영업 0 t_999wg@brsw.kr Y N Y Y 기술영업본부(t_444be@brsw.kr)
23 기술지원 0 t_512gs@brsw.kr Y N Y Y 기술영업본부(t_444be@brsw.kr)
24 견적 0 t_917bs@brsw.kr Y N Y Y 기술영업본부(t_444be@brsw.kr)
25 건설본부 0 t_054iq@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
26 공무 0 t_191nh@brsw.kr Y N Y Y 건설본부(t_054iq@brsw.kr)
27 현장 0 t_995wn@brsw.kr Y N Y Y 건설본부(t_054iq@brsw.kr)
28 안전관리 0 t_695sg@brsw.kr Y N Y Y 건설본부(t_054iq@brsw.kr)
29 한라산업개발 0 hanlla@brsw.kr Y N Y Y
30 임원실 0 t_080iz@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
31 업무총괄 0 general-biz@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
32 영업총괄 0 general_sales@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
33 경영지원본부 0 t_261yp@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
34 업무팀 0 t_407sk@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
35 사업지원팀 0 t_265al@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
36 경영지원팀 0 t_681nn@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
37 운영사업실 0 t_174mm@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
38 기반사업본부 0 t_785dc@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
39 사업관리팀 0 t_422tk@brsw.kr Y N Y Y 기반사업본부(t_785dc@brsw.kr)
40 환경플랜트사업본부 0 t_558py@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
41 사업관리팀 0 t_932wg@brsw.kr Y N Y Y 환경플랜트사업본부(t_558py@brsw.kr)
42 설계팀 0 t_695kn@brsw.kr Y N Y Y 환경플랜트사업본부(t_558py@brsw.kr)
43 기술영업본부 0 t_708iq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
44 기술영업팀 0 t_026lk@brsw.kr Y N Y Y 기술영업본부(t_708iq@brsw.kr)
45 안전관리본부 0 t_601wg@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
46 안전관리팀 0 t_885ji@brsw.kr Y N Y Y 안전관리본부(t_601wg@brsw.kr)
47 시공현장 0 t_745dt@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
48 부천시 굴포천 0 t_579tx@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
49 옥정 공공하수처리 0 t_644eu@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
50 여주부평천 0 t_923sy@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
51 도척 실촌간 도로 0 t_583wq@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
52 광주공공폐수처리 0 t_481kp@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
53 아포공공하수처리 0 t_654ud@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
54 장량공공하수처리 0 t_007gm@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
55 신천공공하수처리 0 t_328ki@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
56 온산하수처리 0 t_742au@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
57 수도권매립지 제2매립장 0 t_850qe@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
58 인천국제공항 화물 0 t_246jb@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
59 광탄공공하수처리 0 t_256he@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
60 성남시생활폐기물처리 0 t_148dm@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
61 제주공공하수처리 0 t_317lj@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
62 인덕원 동탄 복선전철 제3공구 0 t_227xx@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
63 인덕원 동탄 복선전철 제7공구 0 t_605dg@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
64 경산시 국도대체 0 t_020pv@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
65 수도권광역급행철도B 제4공구 0 t_217mu@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
66 부산항 신항 0 t_282jo@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
67 운영사업소 0 t_993sp@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
68 울산민자소각 0 t_600hp@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)
69 온산바이오 0 t_374ak@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)
70 안성제4차산업단지폐수처리 0 t_749lk@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)
71 서산시자원회수시설 0 t_056hs@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)

View 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)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 (주)장헌 0 jangheon@brsw.kr Y N Y Y
3 임원실 0 jangheon-executive@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
4 생산부 0 jangheon-production@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
5 공무팀 0 jangheon-production-admin@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
6 철근팀 0 jangheon-rebar@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
7 제작1팀 0 jangheon-fab-1@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
8 제작2팀 0 jangheon-fab-2@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
9 품질팀 0 jangheon-quality@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
10 업무지원팀 0 jangheon-business-support@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
11 PTC 0 ptc@brsw.kr Y N Y Y
12 임원실 0 ptc-executive@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
13 영업팀 0 ptc-sales@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
14 사업관리팀 0 ptc-project-management@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
15 시공팀 0 ptc-construction@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
16 설계팀 0 ptc-design@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
17 네이버웍스관리용(바론그룹) 2 슈퍼관리자(su-@samaneng.com) nw-admin-baron-group@brsw.kr N N N Y
18 장헌산업 0 jangheon-sanup@brsw.kr Y N Y Y
19 임원실 0 jangheon-sanup-executive@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
20 경영지원부 0 js-mgmt-support@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
21 기술영업본부 0 js-tech-sales-hq@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
22 영업 0 js-sales@brsw.kr Y N Y Y 기술영업본부(js-tech-sales-hq@brsw.kr)
23 기술지원 0 js-tech-support@brsw.kr Y N Y Y 기술영업본부(js-tech-sales-hq@brsw.kr)
24 견적 0 js-estimation@brsw.kr Y N Y Y 기술영업본부(js-tech-sales-hq@brsw.kr)
25 건설본부 0 js-construction-hq@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
26 공무 0 js-construction-admin@brsw.kr Y N Y Y 건설본부(js-construction-hq@brsw.kr)
27 현장 0 js-site@brsw.kr Y N Y Y 건설본부(js-construction-hq@brsw.kr)
28 안전관리 0 js-safety-management@brsw.kr Y N Y Y 건설본부(js-construction-hq@brsw.kr)
29 한라산업개발 0 hanlla@brsw.kr Y N Y Y
30 임원실 0 hanlla-executive@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
31 업무총괄 0 hanlla-general-business@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
32 영업총괄 0 hanlla-general-sales@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
33 경영지원본부 0 hanlla-mgmt-support-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
34 업무팀 0 hanlla-operations@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
35 사업지원팀 0 hanlla-business-support@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
36 경영지원팀 0 hanlla-mgmt-support@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
37 운영사업실 0 hanlla-operations-office@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
38 기반사업본부 0 hanlla-infra-business-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
39 사업관리팀 0 hanlla-infra-project-mgmt@brsw.kr Y N Y Y 기반사업본부(hanlla-infra-business-hq@brsw.kr)
40 환경플랜트사업본부 0 hanlla-env-plant-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
41 사업관리팀 0 hanlla-env-project-mgmt@brsw.kr Y N Y Y 환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)
42 설계팀 0 hanlla-env-plant-design@brsw.kr Y N Y Y 환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)
43 기술영업본부 0 hanlla-tech-sales-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
44 기술영업팀 0 hanlla-tech-sales-team@brsw.kr Y N Y Y 기술영업본부(hanlla-tech-sales-hq@brsw.kr)
45 안전관리본부 0 hanlla-safety-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
46 안전관리팀 0 hanlla-safety-team@brsw.kr Y N Y Y 안전관리본부(hanlla-safety-hq@brsw.kr)
47 시공현장 0 hanlla-construction-sites@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
48 부천시 굴포천 0 site-bucheon-gulpocheon@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
49 옥정 공공하수처리 0 site-okjeong-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
50 여주부평천 0 site-yeoju-bupyeongcheon@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
51 도척 실촌간 도로 0 site-docheok-silchon-road@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
52 광주공공폐수처리 0 site-gwangju-wastewater@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
53 아포공공하수처리 0 site-apo-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
54 장량공공하수처리 0 site-jangnyang-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
55 신천공공하수처리 0 site-sincheon-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
56 온산하수처리 0 site-onsan-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
57 수도권매립지 제2매립장 0 site-sudokwon-landfill-2@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
58 인천국제공항 화물 0 site-incheon-air-cargo@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
59 광탄공공하수처리 0 site-gwangtan-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
60 성남시생활폐기물처리 0 site-seongnam-waste@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
61 제주공공하수처리 0 site-jeju-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
62 인덕원 동탄 복선전철 제3공구 0 site-indeokwon-dongtan-3@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
63 인덕원 동탄 복선전철 제7공구 0 site-indeokwon-dongtan-7@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
64 경산시 국도대체 0 site-gyeongsan-road@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
65 수도권광역급행철도B 제4공구 0 site-gtx-b-4@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
66 부산항 신항 0 site-busan-new-port@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
67 운영사업소 0 hanlla-operation-sites@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
68 울산민자소각 0 ops-ulsan-incineration@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)
69 온산바이오 0 ops-onsan-bio@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)
70 안성제4차산업단지폐수처리 0 ops-anseong-wwtp@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)
71 서산시자원회수시설 0 ops-seosan-recovery@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)

View File

@@ -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,
},
},
{

View File

@@ -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

View 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();
});
});

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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();
});

View File

@@ -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>

View File

@@ -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));
});
}

View File

@@ -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[] = [
{

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}

View File

@@ -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", () => {

View File

@@ -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 }>();

View File

@@ -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],
);
});
});

View File

@@ -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 (

View File

@@ -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", () => {

View File

@@ -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 }>();

View File

@@ -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", () => {

View 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);
}

View 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,
};
}

View 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"],
};

View File

@@ -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;
}

View File

@@ -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]

View File

@@ -937,7 +937,7 @@ start_import = "임포트 시작"
kicker = "Global Overview"
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
description = "전체 또는 선택한 회사 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.admin.overview.playbook]

View File

@@ -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}",
],
};

View 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,
);
});
});

View File

@@ -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,
}) => {

View File

@@ -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,

View File

@@ -12,4 +12,9 @@ export default defineConfig({
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
server: {
fs: {
allow: [".."],
},
},
});

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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)),
),
);
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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}",
],
};

View File

@@ -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

View File

@@ -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 .",

View 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}`;
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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}",
],
};

View File

@@ -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",
},

View 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"

View 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"

View File

@@ -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="

View File

@@ -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"

View 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"