1
0
forked from baron/baron-sso

동기화 기초구조 마련

This commit is contained in:
2026-05-12 12:25:31 +09:00
parent 3063450ee0
commit 5e649c279f
33 changed files with 3364 additions and 408 deletions

View File

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

View File

@@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { ParentTenantSelector } from "./ParentTenantSelector";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("ParentTenantSelector picker", () => {
it("opens an org-chart picker modal and applies tenant selection messages", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
});
it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
excludeTenantId="company-1"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
});
it("selects a non-hanmac parent from the local tenant picker", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartPickerLabel="한맥가족에서 선택"
localPickerLabel="다른 테넌트 선택"
localTenantFilter={(tenant) => tenant.slug !== "hanmac-family"}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" }));
fireEvent.change(
screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"),
{ target: { value: "saman" } },
);
fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ }));
expect(onChange).toHaveBeenCalledWith("company-1");
});
});

View File

@@ -1,9 +1,21 @@
import { Search } from "lucide-react"; import { Building2, X } from "lucide-react";
import { useMemo, useState } from "react"; import type { ReactNode } from "react";
import { Input } from "../../../components/ui/input"; import { useEffect, useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi"; import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
parseOrgChartTenantSelection,
} from "../../users/orgChartPicker";
type ParentTenantSelectorProps = { type ParentTenantSelectorProps = {
id: string; id: string;
@@ -14,6 +26,11 @@ type ParentTenantSelectorProps = {
noneLabel: string; noneLabel: string;
helpText?: string; helpText?: string;
excludeTenantId?: string; excludeTenantId?: string;
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
}; };
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]); const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
@@ -45,70 +62,187 @@ export function ParentTenantSelector({
noneLabel, noneLabel,
helpText, helpText,
excludeTenantId, excludeTenantId,
labelAction,
contextLabel,
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
}: ParentTenantSelectorProps) { }: ParentTenantSelectorProps) {
const [search, setSearch] = useState(""); const [pickerOpen, setPickerOpen] = useState(false);
const [companyOnly, setCompanyOnly] = useState(false); const [localPickerOpen, setLocalPickerOpen] = useState(false);
const filteredTenants = useMemo( const [localSearch, setLocalSearch] = useState("");
() => filterParentTenants(tenants, search, companyOnly, excludeTenantId),
[tenants, search, companyOnly, excludeTenantId],
);
const selectedTenant = tenants.find((tenant) => tenant.id === value); const selectedTenant = tenants.find((tenant) => tenant.id === value);
const optionTenants = const localCandidates = filterParentTenants(
selectedTenant && localTenantFilter ? tenants.filter(localTenantFilter) : tenants,
!filteredTenants.some((tenant) => tenant.id === selectedTenant.id) localSearch,
? [selectedTenant, ...filteredTenants] false,
: filteredTenants; excludeTenantId,
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
);
useEffect(() => {
if (!pickerOpen) return;
const onMessage = (event: MessageEvent) => {
const selection = parseOrgChartTenantSelection(event.data);
if (!selection) return;
if (excludeTenantId && selection.id === excludeTenantId) return;
onChange(selection.id);
setPickerOpen(false);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [excludeTenantId, onChange, pickerOpen]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={id} className="text-sm font-semibold"> <div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
{label} <Label className="text-sm font-semibold">
</Label> {label}
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]"> </Label>
<label className="relative block"> {labelAction}
<Search
aria-hidden="true"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={`${id}-search`}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
placeholder={t(
"ui.admin.tenants.parent.search_placeholder",
"이름 또는 slug 검색",
)}
/>
</label>
<label className="flex h-9 items-center gap-2 rounded-md border border-input px-3 text-sm">
<input
type="checkbox"
checked={companyOnly}
onChange={(event) => setCompanyOnly(event.target.checked)}
className="h-4 w-4"
/>
{t("ui.admin.tenants.parent.company_only", "회사/그룹사만 표시")}
</label>
</div> </div>
<select <input
id={id} id={id}
name={id} name={id}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" type="hidden"
value={value} value={value}
onChange={(event) => onChange(event.target.value)} readOnly
> />
<option value="">{noneLabel}</option> <div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
{optionTenants.map((tenant) => ( <Button
<option key={tenant.id} value={tenant.id}> type="button"
{tenant.name} ({tenant.slug}) - {tenant.type} variant="outline"
</option> size="sm"
))} onClick={() => setPickerOpen(true)}
</select> >
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
{localPickerLabel && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setLocalPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
)}
{selectedTenant ? (
<>
<span className="text-xs text-muted-foreground">
{selectedTenant.slug} · {selectedTenant.type}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onChange("")}
aria-label={noneLabel}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">{noneLabel}</span>
)}
{contextLabel && (
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
{contextLabel}
</span>
)}
</div>
{helpText && ( {helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p> <p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)} )}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react"; import { Building2, Sparkles } from "lucide-react";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -22,6 +22,13 @@ import {
type ServerDomainConflict, type ServerDomainConflict,
formatDomainConflictMessage, formatDomainConflictMessage,
} from "../utils/domainTags"; } from "../utils/domainTags";
import {
ORG_UNIT_TYPE_OPTIONS,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
function TenantCreatePage() { function TenantCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -29,6 +36,9 @@ function TenantCreatePage() {
const [type, setType] = useState("COMPANY"); const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState(""); const [parentId, setParentId] = useState("");
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [status, setStatus] = useState("active"); const [status, setStatus] = useState("active");
const [domains, setDomains] = useState<string[]>([]); const [domains, setDomains] = useState<string[]>([]);
@@ -40,6 +50,31 @@ function TenantCreatePage() {
queryKey: ["tenants", { limit: 1000 }], queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchTenants(1000, 0),
}); });
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
return true;
}
return shouldAllowHanmacOrgConfig(selectedParentTenant, tenants);
}, [selectedParentTenant, tenants]);
const canEditTenantDetails =
parentStepConfirmed || Boolean(selectedParentTenant);
const parentContextLabel = selectedParentTenant
? canConfigureHanmacOrg
? t("ui.admin.tenants.create.parent_context.hanmac", "한맥가족 하위 테넌트")
: t("ui.admin.tenants.create.parent_context.general", "일반 하위 테넌트")
: parentStepConfirmed
? t("ui.admin.tenants.create.parent_context.root", "최상위 테넌트")
: t(
"ui.admin.tenants.create.parent_context.pick_required",
"상위 테넌트 선택 필요",
);
const handleParentChange = (nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
};
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) => mutationFn: (overrideForceDomains?: string[]) =>
@@ -51,6 +86,9 @@ function TenantCreatePage() {
description: description || undefined, description: description || undefined,
status, status,
domains, domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts, forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}), }),
onSuccess: () => { onSuccess: () => {
@@ -115,152 +153,266 @@ function TenantCreatePage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div
<Label htmlFor="tenant-name" className="text-sm font-semibold"> data-testid="tenant-parent-org-config-layout"
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "} className="grid gap-4 md:grid-cols-4"
<span className="text-destructive">*</span> >
</Label> <div
<Input data-testid="tenant-parent-picker-slot"
id="tenant-name" className={
name="name" canConfigureHanmacOrg ? "md:col-span-2" : "md:col-span-4"
value={name} }
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-type" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.create.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
> >
{t("ui.admin.tenants.create.form.description", "설명")} <ParentTenantSelector
</Label> id="parentId"
<Textarea label={t(
id="tenant-description" "ui.admin.tenants.create.form.parent",
name="description" "상위 테넌트 (선택)",
rows={3} )}
value={description} value={parentId}
onChange={(e) => setDescription(e.target.value)} onChange={handleParentChange}
/> tenants={tenants}
</div> noneLabel={t("ui.common.none", "없음")}
<div className="space-y-2"> contextLabel={parentContextLabel}
<Label htmlFor="tenant-domains" className="text-sm font-semibold"> orgChartPickerLabel={t(
{t( "ui.admin.tenants.create.form.pick_hanmac_parent",
"ui.admin.tenants.create.form.domains_label", "한맥가족에서 선택",
"허용된 도메인 (콤마로 구분)", )}
)} localPickerLabel={t(
</Label> "ui.admin.tenants.create.form.pick_other_parent",
<DomainTagInput "다른 테넌트 선택",
id="tenant-domains" )}
value={domains} localTenantFilter={(tenant) =>
onChange={setDomains} tenant.slug.toLowerCase() !== "hanmac-family" &&
tenants={parentQuery.data?.items ?? []} !shouldAllowHanmacOrgConfig(tenant, tenants)
confirmedConflicts={forceDomainConflicts} }
onConfirmedConflictsChange={setForceDomainConflicts} labelAction={
placeholder={t( !selectedParentTenant ? (
"ui.admin.tenants.create.form.domains_placeholder", <Button
"example.com, example.kr", type="button"
)} variant={parentStepConfirmed ? "default" : "outline"}
/> size="sm"
<p className="text-xs text-muted-foreground"> onClick={() => setParentStepConfirmed(true)}
{t( >
"msg.admin.tenants.create.form.domains_help", {t(
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", "ui.admin.tenants.create.form.root_tenant",
)} "최상위 테넌트로 생성",
</p> )}
</div> </Button>
<div className="space-y-2"> ) : null
<Label className="text-sm font-semibold"> }
{t("ui.admin.tenants.create.form.status", "상태")} />
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div> </div>
{canConfigureHanmacOrg && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
>
<Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<select
id="tenant-org-unit-type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div
data-testid="tenant-visibility-slot"
className="space-y-2"
>
<Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={visibility}
onChange={(event) =>
setVisibility(event.target.value as TenantVisibility)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</>
)}
</div> </div>
{canEditTenantDetails && (
<>
<div className="space-y-2">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="tenant-type"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-domains"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={tenants}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</>
)}
{!canEditTenantDetails && (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.create.pick_parent_first",
"상위 테넌트를 먼저 선택하세요.",
)}
</div>
)}
{errorMsg && ( {errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">

View File

@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -40,10 +42,39 @@ function TenantDetailPage() {
<div className="space-y-8"> <div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-3xl font-semibold"> <div
{tenantQuery.data?.name ?? className="flex flex-wrap items-center gap-3"
t("ui.admin.tenants.detail.loading", "불러오는 중...")} data-testid="tenant-detail-title-row"
</h2> >
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
{tenantQuery.data?.id && (
<div
className="flex items-center gap-1.5"
data-testid="tenant-detail-uuid"
>
<code className="select-all rounded-md border border-border bg-muted/40 px-2 py-1 font-mono text-xs text-foreground">
{tenantQuery.data.id}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void navigator.clipboard?.writeText(tenantQuery.data.id);
}}
aria-label="테넌트 UUID 복사"
title="테넌트 UUID 복사"
data-testid="tenant-detail-copy-uuid"
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
</div>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
{t( {t(
"ui.admin.tenants.detail.header_subtitle", "ui.admin.tenants.detail.header_subtitle",

View File

@@ -141,6 +141,14 @@ function resolveDefaultImportParentRef(
tenants: TenantSummary[], tenants: TenantSummary[],
) { ) {
if (preview.row.parentTenantId) { if (preview.row.parentTenantId) {
const parentPreview = previewRows.find(
(candidate) =>
candidate.row.rowNumber !== preview.row.rowNumber &&
candidate.row.tenantId === preview.row.parentTenantId,
);
if (parentPreview) {
return previewParentRef(parentPreview.row.rowNumber);
}
return tenantParentRef(preview.row.parentTenantId); return tenantParentRef(preview.row.parentTenantId);
} }
if (!preview.row.parentTenantSlug) { if (!preview.row.parentTenantSlug) {

View File

@@ -288,22 +288,82 @@ export function TenantProfilePage() {
</option> </option>
</select> </select>
</div> </div>
<ParentTenantSelector <div
id="parentId" data-testid="tenant-parent-org-config-layout"
label={t( className="grid gap-4 md:grid-cols-4"
"ui.admin.tenants.profile.form.parent", >
"상위 테넌트 (선택)", <div
data-testid="tenant-parent-picker-slot"
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.profile.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
helpText={t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
excludeTenantId={tenantId}
/>
</div>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div
data-testid="tenant-visibility-slot"
className="space-y-2"
>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tenantVisibility}
onChange={(event) =>
setTenantVisibility(
event.target.value as TenantVisibility,
)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</>
)} )}
value={parentId} </div>
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
helpText={t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
excludeTenantId={tenantId}
/>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")} {t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
@@ -365,45 +425,6 @@ export function TenantProfilePage() {
</Button> </Button>
</div> </div>
</div> </div>
{canEditOrgConfig && (
<div className="grid gap-4 rounded-md border border-border/70 p-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.org_unit_type", "조직 세부타입")}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tenantVisibility}
onChange={(event) =>
setTenantVisibility(event.target.value as TenantVisibility)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
)}
{errorMsg && ( {errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg} {errorMsg}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi"; import type { TenantSummary } from "../../../lib/adminApi";
import { import {
ORG_UNIT_TYPE_OPTIONS,
mergeTenantOrgConfig, mergeTenantOrgConfig,
readTenantOrgConfig, readTenantOrgConfig,
shouldAllowHanmacOrgConfig, shouldAllowHanmacOrgConfig,
@@ -49,6 +50,9 @@ describe("tenant org config", () => {
expect( expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }), readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" }); ).toEqual({ orgUnitType: "팀", visibility: "private" });
expect(
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
).toEqual({ orgUnitType: "센터", visibility: "internal" });
expect( expect(
mergeTenantOrgConfig( mergeTenantOrgConfig(
@@ -57,4 +61,17 @@ describe("tenant org config", () => {
), ),
).toEqual({ userSchema: [], visibility: "internal" }); ).toEqual({ userSchema: [], visibility: "internal" });
}); });
it("includes task-force and executive-direct org unit types", () => {
expect(ORG_UNIT_TYPE_OPTIONS).toEqual(
expect.arrayContaining(["TF", "TF팀", "임원직속"]),
);
expect(readTenantOrgConfig({ orgUnitType: "TF" }).orgUnitType).toBe("TF");
expect(readTenantOrgConfig({ orgUnitType: "TF팀" }).orgUnitType).toBe(
"TF팀",
);
expect(readTenantOrgConfig({ orgUnitType: "임원직속" }).orgUnitType).toBe(
"임원직속",
);
});
}); });

View File

@@ -3,11 +3,15 @@ import type { TenantSummary } from "../../../lib/adminApi";
export const ORG_UNIT_TYPE_OPTIONS = [ export const ORG_UNIT_TYPE_OPTIONS = [
"실", "실",
"팀", "팀",
"TF",
"TF팀",
"센터",
"디비전", "디비전",
"셀", "셀",
"본부", "본부",
"지역본부", "지역본부",
"부", "부",
"임원직속",
] as const; ] as const;
export const TENANT_VISIBILITY_OPTIONS = [ export const TENANT_VISIBILITY_OPTIONS = [

View File

@@ -206,7 +206,10 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const resolveUserImportTenants = async () => { const resolveUserImportTenants = async () => {
const tenants = tenantQuery.data?.items ?? []; const tenants = tenantQuery.data?.items ?? [];
const tenantSlugByKey = new Map<string, string>(); const tenantByKey = new Map<
string,
{ id: string; slug: string; emailDomain: string }
>();
for (const preview of tenantPreviewRows) { for (const preview of tenantPreviewRows) {
const key = tenantImportKeyFromRow(preview.row); const key = tenantImportKeyFromRow(preview.row);
@@ -215,7 +218,11 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
if (selected !== "__create__") { if (selected !== "__create__") {
const tenant = tenants.find((item) => item.id === selected); const tenant = tenants.find((item) => item.id === selected);
if (tenant) { if (tenant) {
tenantSlugByKey.set(key, tenant.slug); tenantByKey.set(key, {
id: tenant.id,
slug: tenant.slug,
emailDomain: preview.row.emailDomain,
});
} }
continue; continue;
} }
@@ -231,27 +238,33 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
domains: splitTenantImportDomains(preview.row.emailDomain), domains: splitTenantImportDomains(preview.row.emailDomain),
status: "active", status: "active",
}); });
tenantSlugByKey.set(key, created.slug); tenantByKey.set(key, {
id: created.id,
slug: created.slug,
emailDomain: preview.row.emailDomain,
});
} }
return previewData.map((user, index) => { return previewData.map((user, index) => {
const key = tenantImportKeyFromUser(user); const key = tenantImportKeyFromUser(user);
const tenantSlug = key ? tenantSlugByKey.get(key) : user.tenantSlug; const resolvedTenant = key ? tenantByKey.get(key) : undefined;
const emailPreview = hanmacEmailPreviews[index]; const emailPreview = hanmacEmailPreviews[index];
const { tenantImport: _tenantImport, ...payload } = user; const { tenantImport: _tenantImport, ...payload } = user;
return { return {
...payload, ...payload,
email: emailPreview?.finalEmail ?? payload.email, email: emailPreview?.finalEmail ?? payload.email,
tenantSlug, tenantId: resolvedTenant?.id ?? payload.tenantId,
tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug,
emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain,
}; };
}); });
}; };
const downloadTemplate = () => { const downloadTemplate = () => {
const headers = const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id"; "email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example = const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001"; "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], { const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;", type: "text/csv;charset=utf-8;",
}); });

View File

@@ -82,7 +82,9 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
const result = parseUserCSV(csv); const result = parseUserCSV(csv);
expect(result[0]).toMatchObject({ expect(result[0]).toMatchObject({
tenantId: "local-tenant-id",
tenantSlug: "missing-slug", tenantSlug: "missing-slug",
emailDomain: "missing.example.com",
tenantImport: { tenantImport: {
sourceTenantId: "local-tenant-id", sourceTenantId: "local-tenant-id",
slug: "missing-slug", slug: "missing-slug",
@@ -94,4 +96,36 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
}, },
}); });
}); });
it("should parse one nullable additional appointment from numbered columns", () => {
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책임,팀장,Backend,EMP003,,,,,,`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
tenantSlug: "primary-tenant",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
],
});
expect(result[1].additionalAppointments).toBeUndefined();
});
}); });

View File

@@ -1,4 +1,4 @@
import type { BulkUserItem } from "../../../lib/adminApi"; import type { BulkUserAppointment, BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] { export function parseUserCSV(text: string): BulkUserItem[] {
const records = parseCSVRecords(text.replace(/^\uFEFF/, "")); const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
@@ -15,6 +15,11 @@ export function parseUserCSV(text: string): BulkUserItem[] {
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = { const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {}, metadata: {},
}; };
const additionalAppointment: BulkUserAppointment & {
metadata: Record<string, string>;
} = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) { for (let index = 0; index < headers.length; index++) {
const header = headers[index]; const header = headers[index];
@@ -38,6 +43,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
slug: value, slug: value,
}; };
} else if (header === "tenant_id") { } else if (header === "tenant_id") {
item.tenantId = value;
item.tenantImport = { item.tenantImport = {
...(item.tenantImport ?? {}), ...(item.tenantImport ?? {}),
sourceTenantId: value, sourceTenantId: value,
@@ -73,6 +79,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
memo: value, memo: value,
}; };
} else if (header === "email_domain" || header === "tenant_domain") { } else if (header === "email_domain" || header === "tenant_domain") {
item.emailDomain = value;
item.tenantImport = { item.tenantImport = {
...(item.tenantImport ?? {}), ...(item.tenantImport ?? {}),
emailDomain: value, emailDomain: value,
@@ -85,6 +92,20 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.position = value; item.position = value;
} else if (header === "jobtitle") { } else if (header === "jobtitle") {
item.jobTitle = value; item.jobTitle = value;
} else if (header === "employee_id") {
item.metadata.employee_id = value;
} else if (header === "tenant_slug1") {
additionalAppointment.tenantSlug = value;
} else if (header === "department1") {
additionalAppointment.department = value;
} else if (header === "grade1") {
additionalAppointment.grade = value;
} else if (header === "position1") {
additionalAppointment.position = value;
} else if (header === "jobtitle1") {
additionalAppointment.jobTitle = value;
} else if (header === "employee_id1") {
additionalAppointment.metadata.employee_id = value;
} else if (header === "lastname") { } else if (header === "lastname") {
item.metadata.naverworks_last_name = value; item.metadata.naverworks_last_name = value;
} else if (header === "firstname") { } else if (header === "firstname") {
@@ -149,6 +170,11 @@ export function parseUserCSV(text: string): BulkUserItem[] {
} }
applyNaverWorksFallbacks(item); applyNaverWorksFallbacks(item);
if (additionalAppointment.tenantSlug) {
item.additionalAppointments = [
cleanAdditionalAppointment(additionalAppointment),
];
}
if (item.email && item.name) { if (item.email && item.name) {
data.push(item as BulkUserItem); data.push(item as BulkUserItem);
@@ -158,6 +184,31 @@ export function parseUserCSV(text: string): BulkUserItem[] {
return data; return data;
} }
function cleanAdditionalAppointment(
appointment: BulkUserAppointment & { metadata: Record<string, string> },
) {
const metadata =
Object.keys(appointment.metadata).length > 0
? appointment.metadata
: undefined;
return {
...(appointment.tenantId ? { tenantId: appointment.tenantId } : {}),
...(appointment.tenantSlug ? { tenantSlug: appointment.tenantSlug } : {}),
...(appointment.tenantName ? { tenantName: appointment.tenantName } : {}),
...(appointment.isPrimary !== undefined
? { isPrimary: appointment.isPrimary }
: {}),
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),
...(appointment.jobTitle ? { jobTitle: appointment.jobTitle } : {}),
...(metadata ? { metadata } : {}),
};
}
function normalizeHeader(header: string) { function normalizeHeader(header: string) {
return header return header
.trim() .trim()

View File

@@ -546,17 +546,33 @@ export type UserAppointment = {
position?: string; position?: string;
}; };
export type BulkUserAppointment = {
tenantId?: string;
tenantSlug?: string;
tenantName?: string;
isPrimary?: boolean;
isOwner?: boolean;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type BulkUserItem = { export type BulkUserItem = {
email: string; email: string;
loginId?: string; loginId?: string;
name: string; name: string;
phone?: string; phone?: string;
role?: string; role?: string;
tenantId?: string;
tenantSlug?: string; tenantSlug?: string;
emailDomain?: string;
department?: string; department?: string;
grade?: string; grade?: string;
position?: string; position?: string;
jobTitle?: string; jobTitle?: string;
additionalAppointments?: BulkUserAppointment[];
tenantImport?: { tenantImport?: {
sourceTenantId?: string; sourceTenantId?: string;
slug?: string; slug?: string;

View File

@@ -110,6 +110,9 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000, timeout: 20000,
}); });
await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.click();
const nameInput = page.locator('input[name="name"]').first(); const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill("New Tenant"); await nameInput.fill("New Tenant");
@@ -119,14 +122,221 @@ test.describe("Tenants Management", () => {
await page.locator("textarea").first().fill("Description"); await page.locator("textarea").first().fill("Description");
const submitBtn = page const submitBtn = page.getByRole("button", { name: /^생성$/ });
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
await submitBtn.click(); await submitBtn.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
}); });
test("should ask for parent tenant before tenant details", async ({
page,
}) => {
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
},
{
id: "outside-1",
name: "외부회사",
slug: "outside",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: null,
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const headers = { "Access-Control-Allow-Origin": "*" };
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
});
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await expect(
page.getByRole("button", { name: "한맥가족에서 선택" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "다른 테넌트 선택" }),
).toBeVisible();
const parentLabelTop = await page
.getByText(/상위 테넌트/)
.first()
.evaluate((element) => element.getBoundingClientRect().top);
const rootButtonTop = await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.evaluate((element) => element.getBoundingClientRect().top);
expect(Math.abs(parentLabelTop - rootButtonTop)).toBeLessThan(10);
await expect(page.locator('input[name="name"]')).toHaveCount(0);
await page.getByRole("button", { name: "다른 테넌트 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByPlaceholder("테넌트 이름 또는 슬러그 검색").fill("outside");
await page.getByRole("button", { name: /외부회사/ }).click();
await expect(
page
.getByTestId("tenant-parent-picker-slot")
.getByText("outside · COMPANY"),
).toBeVisible();
await expect(page.getByText("일반 하위 테넌트")).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toHaveCount(0);
await expect(page.getByLabel("공개 범위")).toHaveCount(0);
await page
.getByTestId("tenant-parent-picker-slot")
.getByRole("button", { name: "한맥가족에서 선택" })
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [
{ type: "tenant", id: "family-1", name: "한맥가족" },
],
},
},
window.location.origin,
);
});
await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible();
await expect(page.getByText("한맥가족 하위 테넌트")).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toBeVisible();
await expect(page.getByLabel("공개 범위")).toBeVisible();
});
test("should create a hanmac-family child tenant with org config", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
let createBody = "";
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") {
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
}
if (method === "POST") {
createBody = route.request().postData() ?? "";
return route.fulfill({
json: { id: "created-tenant-id", name: "신규 센터" },
headers,
});
}
return route.fulfill({ json: {}, headers });
});
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [
{ type: "tenant", id: "family-1", name: "한맥가족" },
],
},
},
window.location.origin,
);
});
await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible();
await expect(page.getByLabel("조직 세부타입")).toBeVisible();
await expect(page.getByLabel("공개 범위")).toBeVisible();
const layout = page.getByTestId("tenant-parent-org-config-layout");
const parentWidth = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const columns = await layout.evaluate((element) =>
window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
await page.locator('input[name="name"]').first().fill("신규 센터");
await page.locator('input[name="slug"]').first().fill("new-center");
await page.getByLabel("조직 세부타입").selectOption("센터");
await page.getByLabel("공개 범위").selectOption("internal");
await page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first()
.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
expect(JSON.parse(createBody)).toMatchObject({
parentId: "family-1",
config: {
orgUnitType: "센터",
visibility: "internal",
},
});
});
test("should export and import tenant CSV without organization/user combined import", async ({ test("should export and import tenant CSV without organization/user combined import", async ({
page, page,
browserName, browserName,
@@ -320,11 +530,11 @@ test.describe("Tenants Management", () => {
expect(importBody).not.toContain("local-parent-id"); expect(importBody).not.toContain("local-parent-id");
expect(importBody).not.toContain("local-child-id"); expect(importBody).not.toContain("local-child-id");
const parentMatch = importBody.match( const parentMatch = importBody.match(
/([0-9a-f-]{36}),Parent Tenant,COMPANY,,parent-created/, /([0-9a-f-]{36}),Parent Tenant,COMPANY,,,parent-created/,
); );
expect(parentMatch?.[1]).toBeTruthy(); expect(parentMatch?.[1]).toBeTruthy();
expect(importBody).toContain( expect(importBody).toContain(
`,Child Tenant,USER_GROUP,${parentMatch?.[1]},child-created`, `,Child Tenant,USER_GROUP,${parentMatch?.[1]},parent-created,child-created`,
); );
}); });
@@ -333,11 +543,11 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000, timeout: 20000,
}); });
await page
.getByRole("button", { name: "최상위 테넌트로 생성" })
.click();
const submitBtn = page const submitBtn = page.getByRole("button", { name: /^생성$/ });
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
await expect(submitBtn).toBeDisabled(); await expect(submitBtn).toBeDisabled();
await page.locator('input[name="name"]').first().fill("Valid Name"); await page.locator('input[name="name"]').first().fill("Valid Name");
@@ -408,4 +618,120 @@ test.describe("Tenants Management", () => {
.first(), .first(),
).toBeVisible(); ).toBeVisible();
}); });
test("should show tenant UUID at the top of tenant detail profile", async ({
page,
}) => {
const tenantUuid = "11111111-2222-4333-8444-555555555555";
const tenant = {
id: tenantUuid,
name: "Tenant With UUID",
slug: "tenant-with-uuid",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: null,
config: {},
domains: [],
updatedAt: new Date().toISOString(),
};
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes(`/admin/tenants/${tenantUuid}`)) {
return route.fulfill({ json: tenant, headers });
}
return route.fulfill({
json: { items: [tenant], total: 1, limit: 1000, offset: 0 },
headers,
});
});
await page.goto(`/tenants/${tenantUuid}`);
const titleRow = page.getByTestId("tenant-detail-title-row");
await expect(titleRow).toBeVisible({ timeout: 20000 });
await expect(titleRow).toContainText("Tenant With UUID");
await expect(titleRow).toContainText(tenantUuid);
await expect(titleRow).not.toContainText("Tenant UUID");
await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible();
});
test("should place hanmac org config beside parent tenant picker", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
const tenants = [
{
id: "family-1",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
parentId: null,
config: {},
},
{
id: "company-1",
name: "삼안",
slug: "saman",
status: "active",
type: "COMPANY",
memberCount: 0,
parentId: "family-1",
config: {},
},
{
id: "team-1",
name: "기획팀",
slug: "planning",
status: "active",
type: "USER_GROUP",
memberCount: 0,
parentId: "company-1",
config: { orgUnitType: "팀", visibility: "internal" },
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/admin/tenants/team-1")) {
return route.fulfill({ json: tenants[2], headers });
}
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
headers,
});
});
await page.goto("/tenants/team-1");
const layout = page.getByTestId("tenant-parent-org-config-layout");
await expect(layout).toBeVisible({ timeout: 20000 });
await expect(layout).toContainText("상위 테넌트");
await expect(layout).toContainText("조직 세부타입");
await expect(layout).toContainText("공개 범위");
const columns = await layout.evaluate((element) =>
window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
const parentWidth = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
});
}); });

View File

@@ -119,7 +119,7 @@ test.describe("Users Bulk Upload", () => {
const requests: string[] = []; const requests: string[] = [];
let bulkPayload = ""; let bulkPayload = "";
await page.route("**/api/v1/admin/tenants", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method(); const method = route.request().method();
requests.push(`${method} ${route.request().url()}`); requests.push(`${method} ${route.request().url()}`);
@@ -184,6 +184,124 @@ test.describe("Users Bulk Upload", () => {
await expect(page.getByText("new@test.com")).toBeVisible(); await expect(page.getByText("new@test.com")).toBeVisible();
expect(requests.some((request) => request.startsWith("POST "))).toBe(true); expect(requests.some((request) => request.startsWith("POST "))).toBe(true);
expect(bulkPayload).toContain('"tenantId":"staging-missing-tenant-id"');
expect(bulkPayload).toContain('"tenantSlug":"missing-slug"'); expect(bulkPayload).toContain('"tenantSlug":"missing-slug"');
expect(bulkPayload).toContain('"emailDomain":"missing.example.com"');
});
test("should include one nullable additional appointment from numbered CSV columns", async ({
page,
}) => {
let bulkPayload = "";
await page.unroute("**/api/v1/**");
await page.route("**/api/v1/user/me", async (route) => {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route(/\/api\/v1\/admin\/users(?:\?|$)/, async (route) => {
return route.fulfill({
json: { items: [], total: 0, limit: 50, offset: 0 },
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route(/\/api\/v1\/admin\/tenants(?:\?|$)/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
items: [
{
id: "tenant-primary-id",
name: "Primary Tenant",
slug: "primary-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "tenant-second-id",
name: "Second Tenant",
slug: "second-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 2,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fulfill({
status: 201,
json: {
id: "tenant-created-id",
name: "Primary Tenant",
slug: "primary-tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route("**/api/v1/admin/users/bulk", async (route) => {
bulkPayload = route.request().postData() ?? "";
return route.fulfill({
json: {
results: [{ email: "dual@test.com", success: true, userId: "u-1" }],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
await page.getByTestId("bulk-import-btn").click();
await page.locator('input[type="file"]').setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from(
[
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1",
"dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002",
].join("\n"),
),
});
await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText("dual@test.com")).toBeVisible();
const payload = JSON.parse(bulkPayload);
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
expect(payload.users[0].additionalAppointments).toEqual([
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
]);
}); });
}); });

View File

@@ -628,6 +628,7 @@ func normalizeTenantDomainInputs(values []string) []string {
func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) { func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
normalized := make(domain.JSONMap, len(config)) normalized := make(domain.JSONMap, len(config))
orgUnitTypeError := "orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속"
for key, value := range config { for key, value := range config {
if key == "userSchema" { if key == "userSchema" {
fields, err := normalizeTenantUserSchema(value) fields, err := normalizeTenantUserSchema(value)
@@ -656,14 +657,14 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
if key == "orgUnitType" { if key == "orgUnitType" {
orgUnitType, ok := value.(string) orgUnitType, ok := value.(string)
if !ok { if !ok {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부") return nil, errors.New(orgUnitTypeError)
} }
orgUnitType = strings.TrimSpace(orgUnitType) orgUnitType = strings.TrimSpace(orgUnitType)
if orgUnitType == "" { if orgUnitType == "" {
continue continue
} }
if !isAllowedOrgUnitType(orgUnitType) { if !isAllowedOrgUnitType(orgUnitType) {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부") return nil, errors.New(orgUnitTypeError)
} }
normalized[key] = orgUnitType normalized[key] = orgUnitType
continue continue
@@ -675,7 +676,7 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
func isAllowedOrgUnitType(value string) bool { func isAllowedOrgUnitType(value string) bool {
switch value { switch value {
case "실", "팀", "디비전", "셀", "본부", "지역본부", "부": case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
return true return true
default: default:
return false return false

View File

@@ -730,12 +730,25 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) { func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{ config, err := normalizeTenantConfig(map[string]any{
"visibility": "internal", "visibility": "internal",
"orgUnitType": "", "orgUnitType": "센터",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "internal", config["visibility"]) assert.Equal(t, "internal", config["visibility"])
assert.Equal(t, "", config["orgUnitType"]) assert.Equal(t, "센터", config["orgUnitType"])
}
func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {
for _, orgUnitType := range []string{"TF", "TF팀", "임원직속"} {
t.Run(orgUnitType, func(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{
"orgUnitType": orgUnitType,
})
assert.NoError(t, err)
assert.Equal(t, orgUnitType, config["orgUnitType"])
})
}
} }
func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) { func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) {

View File

@@ -118,6 +118,47 @@ func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any,
return "" return ""
} }
func bulkUserEmailDomainCandidates(emailDomain string, email string) []string {
values := make([]string, 0, 2)
seen := map[string]bool{}
add := func(value string) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" || seen[normalized] {
return
}
seen[normalized] = true
values = append(values, normalized)
}
for _, value := range strings.FieldsFunc(emailDomain, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r'
}) {
add(value)
}
if _, domainPart, err := domain.SplitEmailDomain(email); err == nil {
add(domainPart)
}
return values
}
func bulkUserAssignmentContainsTenant(appointments []any, primaryTenantID string, tenantID string) bool {
if strings.TrimSpace(tenantID) == "" {
return true
}
if primaryTenantID != "" && primaryTenantID == tenantID {
return true
}
for _, item := range appointments {
appointment, ok := item.(map[string]any)
if !ok {
continue
}
if normalizeMetadataString(appointment["tenantId"]) == tenantID {
return true
}
}
return false
}
func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) { func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
for _, key := range keys { for _, key := range keys {
value, ok := metadata[key] value, ok := metadata[key]
@@ -664,17 +705,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
} }
type bulkUserItem struct { type bulkUserItem struct {
Email string `json:"email"` Email string `json:"email"`
LoginID string `json:"loginId"` LoginID string `json:"loginId"`
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` Role string `json:"role"`
TenantSlug string `json:"tenantSlug"` TenantID string `json:"tenantId"`
Department string `json:"department"` TenantSlug string `json:"tenantSlug"`
Grade string `json:"grade"` EmailDomain string `json:"emailDomain"`
Position string `json:"position"` Department string `json:"department"`
JobTitle string `json:"jobTitle"` Grade string `json:"grade"`
Metadata map[string]any `json:"metadata"` Position string `json:"position"`
JobTitle string `json:"jobTitle"`
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
Metadata map[string]any `json:"metadata"`
} }
type bulkUserResult struct { type bulkUserResult struct {
@@ -720,15 +764,103 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
// Pre-fetch tenant data to avoid redundant DB calls // Pre-fetch tenant data to avoid redundant DB calls
type tenantCacheItem struct { type tenantCacheItem struct {
ID string ID string
Slug string
Name string
Schema []interface{} Schema []interface{}
Groups []domain.UserGroup Groups []domain.UserGroup
LoginIDField string LoginIDField string
} }
tenantCache := make(map[string]tenantCacheItem) tenantCache := make(map[string]tenantCacheItem)
tenantCacheByID := make(map[string]tenantCacheItem)
tenantCacheByDomain := make(map[string]tenantCacheItem)
buildTenantCacheItem := func(tenant *domain.Tenant) tenantCacheItem {
tItem := tenantCacheItem{
ID: tenant.ID,
Slug: tenant.Slug,
Name: tenant.Name,
}
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
tItem.Schema = s
}
if lf, ok := tenant.Config["loginIdField"].(string); ok {
tItem.LoginIDField = lf
}
if h.UserGroupRepo != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
tItem.Groups = groups
}
}
return tItem
}
cacheTenantItem := func(tItem tenantCacheItem) tenantCacheItem {
if tItem.Slug != "" {
tenantCache[strings.ToLower(strings.TrimSpace(tItem.Slug))] = tItem
}
if tItem.ID != "" {
tenantCacheByID[tItem.ID] = tItem
}
return tItem
}
resolveTenantBySlug := func(slug string) (tenantCacheItem, error) {
normalizedSlug := strings.ToLower(strings.TrimSpace(slug))
if normalizedSlug == "" {
return tenantCacheItem{}, errors.New("tenantSlug is required")
}
if tItem, exists := tenantCache[normalizedSlug]; exists {
return tItem, nil
}
if h.TenantService == nil {
return tenantCacheItem{}, errors.New("tenant service unavailable")
}
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), normalizedSlug)
if err != nil || tenant == nil {
return tenantCacheItem{}, errors.New("invalid tenantSlug: tenant not found")
}
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
}
resolveTenantByID := func(tenantID string) (tenantCacheItem, error) {
normalizedID := strings.TrimSpace(tenantID)
if normalizedID == "" {
return tenantCacheItem{}, errors.New("tenantId is required")
}
if tItem, exists := tenantCacheByID[normalizedID]; exists {
return tItem, nil
}
if h.TenantService == nil {
return tenantCacheItem{}, errors.New("tenant service unavailable")
}
tenant, err := h.TenantService.GetTenant(c.Context(), normalizedID)
if err != nil || tenant == nil {
return tenantCacheItem{}, errors.New("invalid tenantId: tenant not found")
}
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
}
resolveTenantByDomain := func(domainName string) (tenantCacheItem, bool) {
normalizedDomain := strings.ToLower(strings.TrimSpace(domainName))
if normalizedDomain == "" || h.TenantService == nil {
return tenantCacheItem{}, false
}
if tItem, exists := tenantCacheByDomain[normalizedDomain]; exists {
return tItem, true
}
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), normalizedDomain)
if err != nil || tenant == nil {
return tenantCacheItem{}, false
}
tItem := cacheTenantItem(buildTenantCacheItem(tenant))
tenantCacheByDomain[normalizedDomain] = tItem
return tItem, true
}
for _, item := range req.Users { for _, item := range req.Users {
email := strings.TrimSpace(item.Email) email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := strings.TrimSpace(item.TenantSlug) tenantSlug := strings.TrimSpace(item.TenantSlug)
dept := strings.TrimSpace(item.Department) dept := strings.TrimSpace(item.Department)
@@ -737,9 +869,38 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue continue
} }
if tenantSlug == "" { var tItem tenantCacheItem
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"}) var err error
continue if tenantID != "" {
tItem, err = resolveTenantByID(tenantID)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
if tenantSlug != "" && !strings.EqualFold(tenantSlug, tItem.Slug) {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantId and tenantSlug do not match"})
continue
}
tenantSlug = tItem.Slug
} else if tenantSlug != "" {
tItem, err = resolveTenantBySlug(tenantSlug)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
tenantSlug = tItem.Slug
} else {
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
tItem = domainTenant
tenantSlug = domainTenant.Slug
break
}
}
if tenantSlug == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant assignment is required"})
continue
}
} }
// Role-based access check // Role-based access check
@@ -750,33 +911,47 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
} }
} }
// Verify Tenant Existence and Resolve ID (with Cache) resolvedAppointments := make([]any, 0, len(item.AdditionalAppointments)+2)
var tItem tenantCacheItem if len(item.AdditionalAppointments) > 0 {
var exists bool appointmentFailed := false
if tItem, exists = tenantCache[tenantSlug]; !exists { for _, rawAppointment := range item.AdditionalAppointments {
if h.TenantService != nil { appointmentTenantSlug := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantSlug"]))
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug) if appointmentTenantSlug == "" {
if err != nil || tenant == nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"})
continue continue
} }
tItem.ID = tenant.ID if requester != nil && requester.Role == domain.RoleTenantAdmin && appointmentTenantSlug != requester.CompanyCode {
if s, ok := tenant.Config["userSchema"].([]interface{}); ok { results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
tItem.Schema = s appointmentFailed = true
break
} }
if lf, ok := tenant.Config["loginIdField"].(string); ok {
tItem.LoginIDField = lf appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)]
} if !exists {
// [Fix] Cache user groups for this tenant to match department appointmentTenant, err = resolveTenantBySlug(appointmentTenantSlug)
if h.UserGroupRepo != nil { if err != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: strings.Replace(err.Error(), "tenantSlug", "additional tenantSlug", 1)})
tItem.Groups = groups appointmentFailed = true
break
} }
} }
tenantCache[tenantSlug] = tItem appointment := make(map[string]any, len(rawAppointment)+3)
} else { for key, value := range rawAppointment {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"}) if key == "tenantSlug" || key == "tenantId" || key == "tenantName" {
continue
}
appointment[key] = value
}
appointment["tenantId"] = appointmentTenant.ID
appointment["tenantSlug"] = appointmentTenant.Slug
if name := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantName"])); name != "" {
appointment["tenantName"] = name
} else {
appointment["tenantName"] = appointmentTenant.Name
}
resolvedAppointments = append(resolvedAppointments, appointment)
}
if appointmentFailed {
continue continue
} }
} }
@@ -836,6 +1011,26 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
} }
} }
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, userEmail) {
domainTenant, ok := resolveTenantByDomain(domainName)
if !ok || bulkUserAssignmentContainsTenant(resolvedAppointments, tItem.ID, domainTenant.ID) {
continue
}
resolvedAppointments = append(resolvedAppointments, map[string]any{
"tenantId": domainTenant.ID,
"tenantSlug": domainTenant.Slug,
"tenantName": domainTenant.Name,
"assignmentSource": "email_domain",
"sourceDomain": strings.ToLower(strings.TrimSpace(domainName)),
})
}
if len(resolvedAppointments) > 0 {
if item.Metadata == nil {
item.Metadata = map[string]any{}
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
password, _ := utils.GeneratePasswordWithPolicy(policy) password, _ := utils.GeneratePasswordWithPolicy(policy)
role := item.Role role := item.Role
if role == "" { if role == "" {

View File

@@ -139,6 +139,19 @@ func (m *MockTenantServiceForUser) GetTenant(ctx context.Context, id string) (*d
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantServiceForUser) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "GetTenantByDomain" {
args := m.Called(ctx, emailDomain)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
}
return nil, nil
}
func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID) args := m.Called(ctx, userID)
if args.Get(0) == nil { if args.Get(0) == nil {
@@ -432,6 +445,217 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
}) })
} }
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-primary",
Slug: "test-tenant",
Name: "Primary Tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-primary").Return(&domain.Tenant{
ID: "t-primary",
Slug: "test-tenant",
Name: "Primary Tenant",
Config: domain.JSONMap{},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "second-tenant").Return(&domain.Tenant{
ID: "t-second",
Slug: "second-tenant",
Name: "Second Tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
appointments, ok := user.Attributes["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
if !ok {
return false
}
metadata, _ := appointment["metadata"].(map[string]any)
return appointment["tenantId"] == "t-second" &&
appointment["tenantSlug"] == "second-tenant" &&
appointment["tenantName"] == "Second Tenant" &&
appointment["department"] == "센터" &&
appointment["grade"] == "수석" &&
appointment["jobTitle"] == "Architecture" &&
metadata["employee_id"] == "EMP002"
}), mock.Anything).Return("u-appointment", nil).Once()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "dual@test.com",
"name": "Dual User",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"employee_id": "EMP001"},
"additionalAppointments": []map[string]interface{}{
{
"tenantSlug": "second-tenant",
"department": "센터",
"grade": "수석",
"jobTitle": "Architecture",
"metadata": map[string]interface{}{"employee_id": "EMP002"},
},
},
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "gpdtdc").Return(&domain.Tenant{
ID: "t-gpdtdc",
Slug: "gpdtdc",
Name: "GPDTDC",
Config: domain.JSONMap{},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-gpdtdc").Return(&domain.Tenant{
ID: "t-gpdtdc",
Slug: "gpdtdc",
Name: "GPDTDC",
Config: domain.JSONMap{},
}, nil)
mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
if user.Attributes["tenant_id"] != "t-gpdtdc" || user.Attributes["companyCode"] != "gpdtdc" {
return false
}
appointments, ok := user.Attributes["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
if !ok {
return false
}
return appointment["tenantId"] == "t-saman" &&
appointment["tenantSlug"] == "saman" &&
appointment["tenantName"] == "삼안" &&
appointment["assignmentSource"] == "email_domain" &&
appointment["sourceDomain"] == "samaneng.com"
}), mock.Anything).Return("u-domain-assigned", nil).Once()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "user@samaneng.com",
"name": "Domain User",
"tenantSlug": "gpdtdc",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{})
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitTenantMissing(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-saman").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["tenant_id"] == "t-saman" &&
user.Attributes["companyCode"] == "saman" &&
user.Attributes["additionalAppointments"] == nil
}), mock.Anything).Return("u-domain-primary", nil).Once()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "user@samaneng.com",
"name": "Domain Primary User",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{})
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) { func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) {
app := fiber.New() app := fiber.New()
mockKratos := new(MockKratosAdmin) mockKratos := new(MockKratosAdmin)

View File

@@ -28,6 +28,7 @@ const (
type WorksmobileDirectoryClient interface { type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error DeleteUser(ctx context.Context, userID string) error
@@ -36,14 +37,15 @@ type WorksmobileDirectoryClient interface {
} }
type WorksmobileHTTPClient struct { type WorksmobileHTTPClient struct {
BaseURL string BaseURL string
DirectoryToken string DirectoryToken string
SCIMToken string SCIMToken string
HTTPClient *http.Client HTTPClient *http.Client
OAuthConfig WorksmobileOAuthConfig OAuthConfig WorksmobileOAuthConfig
DomainIDs []int64 DomainIDs []int64
tokenCache worksmobileAccessTokenCache OrgUnitWriteDelay time.Duration
now func() time.Time tokenCache worksmobileAccessTokenCache
now func() time.Time
} }
type WorksmobileOAuthConfig struct { type WorksmobileOAuthConfig struct {
@@ -186,6 +188,103 @@ func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload Works
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload) return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
} }
func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
err := c.CreateOrgUnit(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
return c.BackfillOrgUnitExternalKeyByLocalPart(ctx, payload, matchLocalPart)
}
return err
}
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
}
for _, group := range groups {
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
continue
}
if group.ExternalID == payload.OrgUnitExternalKey {
if strings.TrimSpace(group.ID) == "" {
return nil
}
if delay := c.orgUnitWriteDelay(); delay > 0 {
time.Sleep(delay)
}
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 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 {
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)
}
patch := NewWorksmobileOrgUnitPatchPayload(payload)
if patch.Email == "" {
patch.Email = remote.Email
}
return c.PatchOrgUnit(ctx, remote.ID, patch)
}
func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" {
return fmt.Errorf("worksmobile orgunit id is required")
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload)
}
func (c *WorksmobileHTTPClient) ClearOrgUnitExternalKey(ctx context.Context, orgUnitID string, domainID int64) error {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" {
return fmt.Errorf("worksmobile orgunit id is required")
}
payload := map[string]any{
"domainId": domainID,
"orgUnitExternalKey": "",
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload)
}
func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" {
return fmt.Errorf("worksmobile orgunit id is required")
}
if delay := c.orgUnitWriteDelay(); delay > 0 {
time.Sleep(delay)
}
return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), nil)
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload) return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
} }
@@ -611,6 +710,15 @@ type WorksmobileUserPatchPayload struct {
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"` Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
} }
type WorksmobileOrgUnitPatchPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"`
OrgUnitName string `json:"orgUnitName,omitempty"`
OrgUnitExternalKey string `json:"orgUnitExternalKey,omitempty"`
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
DisplayOrder int `json:"displayOrder,omitempty"`
}
type WorksmobileRemoteUser struct { type WorksmobileRemoteUser struct {
ID string `json:"id"` ID string `json:"id"`
ExternalID string `json:"externalId"` ExternalID string `json:"externalId"`
@@ -631,13 +739,15 @@ type WorksmobileRemoteUser struct {
} }
type WorksmobileRemoteGroup struct { type WorksmobileRemoteGroup struct {
ID string `json:"id"` ID string `json:"id"`
ExternalID string `json:"externalId"` ExternalID string `json:"externalId"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
DomainID int64 `json:"domainId"` Email string `json:"email,omitempty"`
DomainName string `json:"domainName"` MailLocalPart string `json:"mailLocalPart,omitempty"`
ParentID string `json:"parentId"` DomainID int64 `json:"domainId"`
ParentName string `json:"parentName"` DomainName string `json:"domainName"`
ParentID string `json:"parentId"`
ParentName string `json:"parentName"`
} }
func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload { func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload {
@@ -681,6 +791,17 @@ func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileU
} }
} }
func NewWorksmobileOrgUnitPatchPayload(payload WorksmobileOrgUnitPayload) WorksmobileOrgUnitPatchPayload {
return WorksmobileOrgUnitPatchPayload{
DomainID: payload.DomainID,
Email: strings.TrimSpace(payload.Email),
OrgUnitName: strings.TrimSpace(payload.OrgUnitName),
OrgUnitExternalKey: strings.TrimSpace(payload.OrgUnitExternalKey),
ParentOrgUnitID: strings.TrimSpace(payload.ParentOrgUnitID),
DisplayOrder: payload.DisplayOrder,
}
}
func worksmobileSCIMPreferredLanguage(locale string) string { func worksmobileSCIMPreferredLanguage(locale string) string {
locale = strings.TrimSpace(locale) locale = strings.TrimSpace(locale)
if locale == "" { if locale == "" {
@@ -716,10 +837,13 @@ func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser {
} }
func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup { func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup {
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
group := WorksmobileRemoteGroup{ group := WorksmobileRemoteGroup{
ID: stringFromMap(resource, "id"), ID: stringFromMap(resource, "id"),
ExternalID: stringFromMap(resource, "externalId"), ExternalID: stringFromMap(resource, "externalId"),
DisplayName: stringFromMap(resource, "displayName"), DisplayName: stringFromMap(resource, "displayName"),
Email: email,
MailLocalPart: worksmobileMailLocalPart(email),
} }
group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource) group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource)
return group return group
@@ -751,15 +875,29 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
} }
func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup { func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup {
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
return WorksmobileRemoteGroup{ return WorksmobileRemoteGroup{
ID: firstStringFromMap(resource, "orgUnitId", "id"), ID: firstStringFromMap(resource, "orgUnitId", "id"),
ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"), ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"),
DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"), DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"),
ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"), Email: email,
ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"), MailLocalPart: worksmobileMailLocalPart(email),
ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"),
ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"),
} }
} }
func worksmobileMailLocalPart(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return ""
}
if at := strings.Index(normalized, "@"); at >= 0 {
normalized = normalized[:at]
}
return strings.TrimSpace(normalized)
}
func parseWorksmobileDirectoryUserName(resource map[string]any) string { func parseWorksmobileDirectoryUserName(resource map[string]any) string {
if value := firstStringFromMap(resource, "displayName", "name"); value != "" { if value := firstStringFromMap(resource, "displayName", "name"); value != "" {
return value return value
@@ -969,3 +1107,13 @@ func (c *WorksmobileHTTPClient) currentTime() time.Time {
} }
return time.Now() return time.Now()
} }
func (c *WorksmobileHTTPClient) orgUnitWriteDelay() time.Duration {
if c.OrgUnitWriteDelay < 0 {
return 0
}
if c.OrgUnitWriteDelay > 0 {
return c.OrgUnitWriteDelay
}
return 1100 * time.Millisecond
}

View File

@@ -262,6 +262,68 @@ func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) {
require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path) require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path)
} }
func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"기술개발센터","email":"tech-dev-center@samaneng.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
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: 0,
}, "tech-dev-center")
require.NoError(t, err)
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.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)
require.Equal(t, "/v1.0/orgunits/works-org-1", transport.requests[2].URL.Path)
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
}
func TestWorksmobileHTTPClientUpsertOrgUnitTreatsExistingExternalKeyConflictAsSuccess(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitExternalKey":"tenant-tech-dev-center","orgUnitName":"기술개발센터"}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300285955},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{
DomainID: 300285955,
OrgUnitName: "기술개발센터",
OrgUnitExternalKey: "tenant-tech-dev-center",
}, "")
require.NoError(t, err)
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, http.MethodGet, transport.requests[1].Method)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
}
func TestWorksmobileLiveJWTTokenExchange(t *testing.T) { func TestWorksmobileLiveJWTTokenExchange(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" { if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" {
t.Skip("live Worksmobile token exchange is disabled") t.Skip("live Worksmobile token exchange is disabled")
@@ -486,6 +548,35 @@ func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenInclu
require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName) require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName)
} }
func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(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: "기술개발센터",
Email: "tech-dev-center@samaneng.com",
MailLocalPart: "tech-dev-center",
},
}
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.Equal(t, "tenant-tech-dev-center", all[0].BaronID)
require.Equal(t, "works-org-1", all[0].WorksmobileID)
require.Empty(t, all[0].ExternalKey)
}
func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) { func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) {
user := parseWorksmobileRemoteUser(map[string]any{ user := parseWorksmobileRemoteUser(map[string]any{
"id": "works-1", "id": "works-1",
@@ -564,6 +655,17 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
require.True(t, *user.PrimaryOrgUnitIsManager) require.True(t, *user.PrimaryOrgUnitIsManager)
} }
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
group := parseWorksmobileDirectoryGroup(map[string]any{
"orgUnitId": "works-org-1",
"orgUnitName": "기술개발센터",
"email": "tech-dev-center@samaneng.com",
})
require.Equal(t, "tech-dev-center@samaneng.com", group.Email)
require.Equal(t, "tech-dev-center", group.MailLocalPart)
}
type fakeWorksmobileOutboxRepo struct { type fakeWorksmobileOutboxRepo struct {
ready []domain.WorksmobileOutbox ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox created []domain.WorksmobileOutbox
@@ -609,9 +711,10 @@ func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, m
} }
type fakeWorksmobileDirectoryClient struct { type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload createdUsers []WorksmobileUserPayload
deletedUsers []string deletedUsers []string
orgUnitMatchKeys []string
} }
type captureRoundTripper struct { type captureRoundTripper struct {
@@ -679,6 +782,12 @@ func (f *fakeWorksmobileDirectoryClient) CreateOrgUnit(ctx context.Context, payl
return nil return nil
} }
func (f *fakeWorksmobileDirectoryClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
f.createdOrgUnits = append(f.createdOrgUnits, payload)
f.orgUnitMatchKeys = append(f.orgUnitMatchKeys, matchLocalPart)
return nil
}
func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
f.createdUsers = append(f.createdUsers, payload) f.createdUsers = append(f.createdUsers, payload)
return nil return nil

View File

@@ -4,8 +4,13 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"context" "context"
"encoding/csv"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os" "os"
"sort"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -105,6 +110,444 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
require.True(t, foundSamanOrgUnit) require.True(t, foundSamanOrgUnit)
} }
func TestWorksmobileLiveSamanOrgUnitProvisioning(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_SAMAN_ORGUNIT_PROVISIONING") != "1" {
t.Skip("live Worksmobile Saman orgunit provisioning is disabled")
}
runWorksmobileLiveCompanyOrgUnitProvisioning(t, "saman", "SAMAN_DOMAIN_ID", nil)
}
func TestWorksmobileLiveGPDTDCOrgUnitProvisioning(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_GPDTDC_ORGUNIT_PROVISIONING") != "1" {
t.Skip("live Worksmobile GPDTDC orgunit provisioning is disabled")
}
runWorksmobileLiveCompanyOrgUnitProvisioning(t, "gpdtdc", "GPDTDC_DOMAIN_ID", map[string]bool{
"56cd0fd7-b62a-43c0-8db9-74a30468d7cb": true,
})
}
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")
}
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"),
})
root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug)
require.NoError(t, err)
tenants, err := listWorksmobileLiveTenantScope(db, root.ID)
require.NoError(t, err)
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...))
targets := worksmobileLiveOrgUnitTargets(t, tenants, tenantByID, *root)
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 {
require.Failf(t, "external key is attached to a different Worksmobile domain", "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)
}
extraWithExternalKey, extraWithoutExternalKey := worksmobileLiveExtraRemoteGroups(remoteGroups, targetByID)
t.Logf("SUMMARY synced=%d extraWithExternalKey=%d extraWithoutExternalKey=%d", len(targets), len(extraWithExternalKey), len(extraWithoutExternalKey))
for _, extra := range extraWithExternalKey {
t.Logf("EXTRA external=%s name=%s email=%s domain=%d", extra.ExternalID, extra.DisplayName, extra.Email, extra.DomainID)
}
for _, extra := range extraWithoutExternalKey {
t.Logf("EXTRA_NO_EXTERNAL id=%s name=%s email=%s domain=%d", extra.ID, extra.DisplayName, extra.Email, extra.DomainID)
}
}
func TestWorksmobileLiveInspectGPDTDCOrgUnits(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_INSPECT_GPDTDC_ORGUNITS") != "1" {
t.Skip("live Worksmobile GPDTDC orgunit inspection is disabled")
}
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"),
})
gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc")
require.NoError(t, err)
tenants, err := listWorksmobileLiveTenantSubtree(db, gpdtdcTenant.ID)
require.NoError(t, err)
gpdtdcDomainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID")
require.True(t, ok, "missing GPDTDC_DOMAIN_ID")
remoteGroups, err := client.ListGroups(ctx)
require.NoError(t, err)
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
remoteByID := map[string]WorksmobileRemoteGroup{}
gpdtdcRemoteCount := 0
for _, group := range remoteGroups {
remoteByID[group.ID] = group
if group.DomainID == gpdtdcDomainID {
gpdtdcRemoteCount++
t.Logf("REMOTE GPDTDC id=%s external=%s name=%s email=%s parent=%s parentName=%s", group.ID, group.ExternalID, group.DisplayName, group.Email, group.ParentID, group.ParentName)
}
if group.ExternalID != "" {
remoteByExternalID[group.ExternalID] = group
}
}
missing := make([]domain.Tenant, 0)
wrongDomain := make([]WorksmobileRemoteGroup, 0)
for _, tenant := range tenants {
if tenant.ID == "56cd0fd7-b62a-43c0-8db9-74a30468d7cb" {
continue
}
remote, ok := remoteByExternalID[tenant.ID]
if !ok {
missing = append(missing, tenant)
continue
}
if remote.DomainID != gpdtdcDomainID {
wrongDomain = append(wrongDomain, remote)
}
}
for _, tenant := range missing {
t.Logf("MISSING LOCAL id=%s slug=%s name=%s parent=%v", tenant.ID, tenant.Slug, tenant.Name, tenant.ParentID)
}
for _, remote := range wrongDomain {
t.Logf("WRONG DOMAIN external=%s name=%s domainID=%d domainName=%s", remote.ExternalID, remote.DisplayName, remote.DomainID, remote.DomainName)
}
remoteUsers, err := client.ListUsers(ctx)
require.NoError(t, err)
usersByPrimaryOrg := map[string]int{}
for _, user := range remoteUsers {
if user.DomainID != gpdtdcDomainID || user.PrimaryOrgUnitID == "" {
continue
}
usersByPrimaryOrg[user.PrimaryOrgUnitID]++
}
for orgID, count := range usersByPrimaryOrg {
group := remoteByID[orgID]
t.Logf("USER PRIMARY ORG orgID=%s count=%d external=%s name=%s email=%s", orgID, count, group.ExternalID, group.DisplayName, group.Email)
}
t.Logf("SUMMARY localOrganizations=%d remoteGPDTDCDomain=%d matchedExternal=%d missing=%d wrongDomain=%d", len(tenants), gpdtdcRemoteCount, len(remoteByExternalID), len(missing), len(wrongDomain))
require.Empty(t, missing)
require.Empty(t, wrongDomain)
}
func TestWorksmobileLiveRecoverGPDTDCOrgUnits(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_RECOVER_GPDTDC_ORGUNITS") != "1" {
t.Skip("live Worksmobile GPDTDC orgunit recovery is disabled")
}
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"),
})
gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc")
require.NoError(t, err)
tenants, err := listWorksmobileLiveTenantSubtree(db, gpdtdcTenant.ID)
require.NoError(t, err)
csvNodes, err := readWorksmobileLiveOrgCSV("../../../adminfront/gpdtdc_org_slugged.csv")
require.NoError(t, err)
requireWorksmobileLiveBaronCSVMatch(t, tenants, csvNodes)
gpdtdcDomainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID")
require.True(t, ok, "missing GPDTDC_DOMAIN_ID")
remoteGroups, err := client.ListGroups(ctx)
require.NoError(t, err)
remoteByID := map[string]WorksmobileRemoteGroup{}
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
for _, group := range remoteGroups {
remoteByID[group.ID] = group
if group.ExternalID != "" {
remoteByExternalID[group.ExternalID] = group
}
}
for _, tenant := range tenants {
current, ok := remoteByExternalID[tenant.ID]
if !ok || current.DomainID == gpdtdcDomainID {
continue
}
t.Logf("CLEAR conflicting external key id=%s external=%s name=%s email=%s domain=%d", current.ID, current.ExternalID, current.DisplayName, current.Email, current.DomainID)
if err := client.ClearOrgUnitExternalKey(ctx, current.ID, current.DomainID); err != nil {
legacyPatch := WorksmobileOrgUnitPatchPayload{
DomainID: current.DomainID,
OrgUnitExternalKey: "legacy-" + current.ID,
}
t.Logf("REKEY conflicting external key id=%s replacement=%s error=%v", current.ID, legacyPatch.OrgUnitExternalKey, err)
require.NoError(t, client.PatchOrgUnit(ctx, current.ID, legacyPatch))
}
time.Sleep(1100 * time.Millisecond)
}
remoteGroups, err = client.ListGroups(ctx)
require.NoError(t, err)
remoteByID = map[string]WorksmobileRemoteGroup{}
remoteByExternalID = map[string]WorksmobileRemoteGroup{}
for _, group := range remoteGroups {
remoteByID[group.ID] = group
if group.ExternalID != "" {
remoteByExternalID[group.ExternalID] = group
}
}
for _, tenant := range tenants {
current, ok := remoteByExternalID[tenant.ID]
if !ok || current.DomainID == gpdtdcDomainID {
continue
}
legacyPatch := WorksmobileOrgUnitPatchPayload{
DomainID: current.DomainID,
OrgUnitExternalKey: "legacy-" + current.ID,
}
t.Logf("REKEY still-conflicting external key id=%s replacement=%s", current.ID, legacyPatch.OrgUnitExternalKey)
require.NoError(t, client.PatchOrgUnit(ctx, current.ID, legacyPatch))
time.Sleep(1100 * time.Millisecond)
}
remoteGroups, err = client.ListGroups(ctx)
require.NoError(t, err)
remoteByID = map[string]WorksmobileRemoteGroup{}
remoteByExternalID = map[string]WorksmobileRemoteGroup{}
for _, group := range remoteGroups {
remoteByID[group.ID] = group
if group.ExternalID != "" {
remoteByExternalID[group.ExternalID] = group
}
}
remoteUsers, err := client.ListUsers(ctx)
require.NoError(t, err)
usersByPrimaryOrg := map[string]int{}
for _, user := range remoteUsers {
if user.DomainID == gpdtdcDomainID && user.PrimaryOrgUnitID != "" {
usersByPrimaryOrg[user.PrimaryOrgUnitID]++
}
}
type recoveryTarget struct {
Tenant domain.Tenant
CSV worksmobileLiveCSVOrg
Target WorksmobileRemoteGroup
Bad *WorksmobileRemoteGroup
}
targets := make([]recoveryTarget, 0)
badByID := map[string]WorksmobileRemoteGroup{}
for _, tenant := range tenants {
node, ok := csvNodes[tenant.Slug]
if !ok {
t.Logf("SKIP no CSV node slug=%s name=%s id=%s", tenant.Slug, tenant.Name, tenant.ID)
continue
}
desiredPath := worksmobileLiveCSVPath(csvNodes, tenant.Slug)
target, found := findWorksmobileLiveRemoteByPath(remoteGroups, remoteByID, gpdtdcDomainID, desiredPath)
if !found {
if current, ok := remoteByExternalID[tenant.ID]; ok && current.DomainID == gpdtdcDomainID {
target = current
found = true
}
}
require.True(t, found, "missing recovery target slug=%s path=%s", tenant.Slug, desiredPath)
var bad *WorksmobileRemoteGroup
if current, ok := remoteByExternalID[tenant.ID]; ok && current.DomainID == gpdtdcDomainID && current.ID != target.ID {
currentCopy := current
bad = &currentCopy
badByID[current.ID] = current
}
targets = append(targets, recoveryTarget{Tenant: tenant, CSV: node, Target: target, Bad: bad})
}
badIDs := map[string]bool{}
for id := range badByID {
collectWorksmobileLiveSubtreeIDs(id, remoteGroups, badIDs)
}
badGroups := make([]WorksmobileRemoteGroup, 0, len(badIDs))
for id := range badIDs {
group := remoteByID[id]
if group.ID == "" {
continue
}
require.Zero(t, usersByPrimaryOrg[id], "refusing to delete orgunit with primary users: %s %s", group.DisplayName, id)
badGroups = append(badGroups, group)
}
sort.SliceStable(badGroups, func(i, j int) bool {
return worksmobileLiveRemoteDepth(remoteByID, badGroups[i]) > worksmobileLiveRemoteDepth(remoteByID, badGroups[j])
})
for _, group := range badGroups {
t.Logf("DELETE duplicate id=%s external=%s name=%s email=%s", group.ID, group.ExternalID, group.DisplayName, group.Email)
require.NoError(t, client.DeleteOrgUnit(ctx, group.ID))
}
for _, target := range targets {
if badIDs[target.Target.ID] {
continue
}
patch := WorksmobileOrgUnitPatchPayload{
DomainID: gpdtdcDomainID,
Email: target.CSV.Email,
OrgUnitName: target.CSV.Name,
OrgUnitExternalKey: target.Tenant.ID,
}
t.Logf("PATCH existing id=%s external=%s name=%s email=%s", target.Target.ID, target.Tenant.ID, target.CSV.Name, target.CSV.Email)
require.NoError(t, client.PatchOrgUnit(ctx, target.Target.ID, patch))
time.Sleep(1100 * time.Millisecond)
}
remoteGroups, err = client.ListGroups(ctx)
require.NoError(t, err)
remoteByExternalID = map[string]WorksmobileRemoteGroup{}
for _, group := range remoteGroups {
if group.ExternalID != "" {
remoteByExternalID[group.ExternalID] = group
}
}
for _, target := range targets {
remote, ok := remoteByExternalID[target.Tenant.ID]
require.True(t, ok, "missing recovered external key for %s", target.Tenant.Slug)
require.Equal(t, gpdtdcDomainID, remote.DomainID)
require.Equal(t, target.CSV.Name, remote.DisplayName)
}
}
func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug string, domainIDEnvKey string, skipTenantIDs map[string]bool) {
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"),
})
companyTenant, err := tenantService.GetTenantBySlug(ctx, companySlug)
require.NoError(t, err)
root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug)
require.NoError(t, err)
tenants, err := listWorksmobileLiveTenantSubtree(db, companyTenant.ID)
require.NoError(t, err)
domainID, ok := worksmobileDomainIDFromEnv(domainIDEnvKey)
require.True(t, ok, "missing %s", domainIDEnvKey)
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, append([]domain.Tenant{*companyTenant}, tenants...)...))
for index, tenant := range sortWorksmobileLiveOrgUnitsTopologically(tenants) {
if skipTenantIDs[tenant.ID] {
continue
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, *companyTenant, root.Config, index+1)
require.NoError(t, err)
payload.DomainID = domainID
if tenant.ParentID != nil && (*tenant.ParentID == companyTenant.ID || skipTenantIDs[*tenant.ParentID]) {
payload.ParentOrgUnitID = ""
} else {
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
}
require.NoError(t, client.UpsertOrgUnit(ctx, payload, tenant.Slug), "tenant=%s slug=%s", tenant.Name, tenant.Slug)
time.Sleep(1100 * time.Millisecond)
}
remoteGroups, err := client.ListGroups(ctx)
require.NoError(t, err)
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
for _, group := range remoteGroups {
if group.ExternalID != "" {
remoteByExternalID[group.ExternalID] = group
}
}
for _, tenant := range tenants {
if skipTenantIDs[tenant.ID] {
continue
}
remote, ok := remoteByExternalID[tenant.ID]
require.True(t, ok, "missing remote orgunit external key for %s %s", tenant.Name, tenant.ID)
require.Equal(t, tenant.Name, remote.DisplayName)
}
}
func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) { func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) {
t.Helper() t.Helper()
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1) payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1)
@@ -119,6 +562,435 @@ func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, cl
require.NoError(t, err) require.NoError(t, err)
} }
func listWorksmobileLiveTenantSubtree(db *gorm.DB, rootID string) ([]domain.Tenant, error) {
var tenants []domain.Tenant
err := db.Raw(`
with recursive scope as (
select id, type, parent_id, name, slug, description, status, config, created_at, updated_at, deleted_at
from tenants
where id = ? and deleted_at is null
union all
select t.id, t.type, t.parent_id, t.name, t.slug, t.description, t.status, t.config, t.created_at, t.updated_at, t.deleted_at
from tenants t
join scope on t.parent_id = scope.id
where t.deleted_at is null
)
select *
from scope
where type = ?
order by name, slug
`, rootID, domain.TenantTypeOrganization).Scan(&tenants).Error
return tenants, err
}
func listWorksmobileLiveTenantScope(db *gorm.DB, rootID string) ([]domain.Tenant, error) {
type tenantIDRow struct {
ID string
}
rows := []tenantIDRow{}
if err := db.Raw(`
with recursive scope as (
select id, parent_id, created_at
from tenants
where id = ? and deleted_at is null
union all
select t.id, t.parent_id, t.created_at
from tenants t
join scope on t.parent_id = scope.id
where t.deleted_at is null
)
select id
from scope
where id <> ?
order by created_at, id
`, rootID, rootID).Scan(&rows).Error; err != nil {
return nil, err
}
ids := make([]string, 0, len(rows))
order := map[string]int{}
for index, row := range rows {
ids = append(ids, row.ID)
order[row.ID] = index
}
if len(ids) == 0 {
return nil, nil
}
tenants := []domain.Tenant{}
if err := db.Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil {
return nil, err
}
sort.SliceStable(tenants, func(i, j int) bool {
return order[tenants[i].ID] < order[tenants[j].ID]
})
return tenants, nil
}
type worksmobileLiveOrgUnitTarget struct {
Tenant domain.Tenant
Payload WorksmobileOrgUnitPayload
}
func worksmobileLiveOrgUnitTargets(t *testing.T, tenants []domain.Tenant, tenantByID map[string]domain.Tenant, root domain.Tenant) []worksmobileLiveOrgUnitTarget {
t.Helper()
targets := make([]worksmobileLiveOrgUnitTarget, 0)
seenExternalKeys := map[string]string{}
seenEmails := map[string]string{}
for index, tenant := range tenants {
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) || worksmobileLiveSkipOrgUnitTenant(tenant) {
continue
}
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, domainTenant, root.Config, index+1)
require.NoError(t, err, "payload build failed: %s", tenant.Slug)
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
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 worksmobileLiveSkipOrgUnitTenant(tenant domain.Tenant) bool {
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
name := strings.TrimSpace(tenant.Name)
return slug == "nw-admin-gpd" || slug == "su2" || name == "네이버웍스관리용"
}
func sortWorksmobileLiveTargetsTopologically(targets []worksmobileLiveOrgUnitTarget, tenantByID map[string]domain.Tenant) []worksmobileLiveOrgUnitTarget {
byID := map[string]worksmobileLiveOrgUnitTarget{}
for _, target := range targets {
byID[target.Tenant.ID] = target
}
remaining := append([]worksmobileLiveOrgUnitTarget(nil), targets...)
sort.SliceStable(remaining, func(i, j int) bool {
left := worksmobileLiveTenantOrgPath(remaining[i].Tenant, tenantByID)
right := worksmobileLiveTenantOrgPath(remaining[j].Tenant, tenantByID)
if left != right {
return left < right
}
return remaining[i].Tenant.Slug < remaining[j].Tenant.Slug
})
done := map[string]bool{}
result := make([]worksmobileLiveOrgUnitTarget, 0, len(remaining))
for len(remaining) > 0 {
progress := false
next := remaining[:0]
for _, target := range remaining {
parentReady := true
if parentID := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentID != "" && parentID != target.Payload.ParentOrgUnitID {
_, parentInTargets := byID[parentID]
parentReady = !parentInTargets || done[parentID]
}
if parentReady {
result = append(result, target)
done[target.Tenant.ID] = true
progress = true
continue
}
next = append(next, target)
}
if !progress {
result = append(result, next...)
break
}
remaining = next
}
return result
}
func worksmobileLiveTenantOrgPath(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string {
names := []string{tenant.Name}
current := tenant
seen := map[string]bool{tenant.ID: true}
for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" {
parent, ok := tenantByID[*current.ParentID]
if !ok || seen[parent.ID] || !isWorksmobileOrgUnitTenant(parent, tenantByID) || worksmobileLiveSkipOrgUnitTenant(parent) {
break
}
seen[parent.ID] = true
names = append([]string{parent.Name}, names...)
current = parent
}
return strings.Join(names, "/")
}
func worksmobileLiveRemoteByExternalID(groups []WorksmobileRemoteGroup) (map[string]WorksmobileRemoteGroup, []string) {
result := map[string]WorksmobileRemoteGroup{}
duplicates := []string{}
seenDuplicate := map[string]bool{}
for _, group := range groups {
if group.ExternalID == "" {
continue
}
if _, exists := result[group.ExternalID]; exists {
if !seenDuplicate[group.ExternalID] {
duplicates = append(duplicates, group.ExternalID)
seenDuplicate[group.ExternalID] = true
}
continue
}
result[group.ExternalID] = group
}
sort.Strings(duplicates)
return result, duplicates
}
func worksmobileLiveRemoteByID(groups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup {
result := map[string]WorksmobileRemoteGroup{}
for _, group := range groups {
result[group.ID] = group
}
return result
}
func patchWorksmobileLiveOrgUnit(ctx context.Context, client *WorksmobileHTTPClient, orgUnitID string, payload WorksmobileOrgUnitPayload) error {
body := map[string]any{
"domainId": payload.DomainID,
"email": strings.TrimSpace(payload.Email),
"orgUnitName": strings.TrimSpace(payload.OrgUnitName),
"orgUnitExternalKey": strings.TrimSpace(payload.OrgUnitExternalKey),
"parentOrgUnitId": strings.TrimSpace(payload.ParentOrgUnitID),
"displayOrder": payload.DisplayOrder,
}
return client.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(strings.TrimSpace(orgUnitID)), body)
}
func worksmobileLiveExtraRemoteGroups(groups []WorksmobileRemoteGroup, targetByID map[string]worksmobileLiveOrgUnitTarget) ([]WorksmobileRemoteGroup, []WorksmobileRemoteGroup) {
extraWithExternalKey := []WorksmobileRemoteGroup{}
extraWithoutExternalKey := []WorksmobileRemoteGroup{}
for _, group := range groups {
if group.ExternalID == "" {
extraWithoutExternalKey = append(extraWithoutExternalKey, group)
continue
}
if _, ok := targetByID[group.ExternalID]; !ok {
extraWithExternalKey = append(extraWithExternalKey, group)
}
}
sort.SliceStable(extraWithExternalKey, func(i, j int) bool {
return extraWithExternalKey[i].DisplayName < extraWithExternalKey[j].DisplayName
})
sort.SliceStable(extraWithoutExternalKey, func(i, j int) bool {
return extraWithoutExternalKey[i].DisplayName < extraWithoutExternalKey[j].DisplayName
})
return extraWithExternalKey, extraWithoutExternalKey
}
func sortWorksmobileLiveOrgUnitsTopologically(tenants []domain.Tenant) []domain.Tenant {
remaining := append([]domain.Tenant(nil), tenants...)
sort.SliceStable(remaining, func(i, j int) bool {
if remaining[i].Name != remaining[j].Name {
return remaining[i].Name < remaining[j].Name
}
return remaining[i].Slug < remaining[j].Slug
})
done := map[string]bool{}
result := make([]domain.Tenant, 0, len(remaining))
for len(remaining) > 0 {
progress := false
next := remaining[:0]
for _, tenant := range remaining {
parentReady := tenant.ParentID == nil || *tenant.ParentID == "" || done[*tenant.ParentID]
if !parentReady {
parentInScope := false
for _, candidate := range remaining {
if candidate.ID == *tenant.ParentID {
parentInScope = true
break
}
}
parentReady = !parentInScope
}
if parentReady {
result = append(result, tenant)
done[tenant.ID] = true
progress = true
continue
}
next = append(next, tenant)
}
if !progress {
result = append(result, next...)
break
}
remaining = next
}
return result
}
type worksmobileLiveCSVOrg struct {
Slug string
Name string
Email string
ParentSlug string
}
func readWorksmobileLiveOrgCSV(path string) (map[string]worksmobileLiveCSVOrg, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
header, err := reader.Read()
if err != nil {
return nil, err
}
index := map[string]int{}
for i, value := range header {
index[strings.TrimSpace(value)] = i
}
result := map[string]worksmobileLiveCSVOrg{}
for {
row, err := reader.Read()
if err == io.EOF {
return result, nil
}
if err != nil {
return nil, err
}
email := csvValue(row, index, "메일링 리스트")
slug := worksmobileMailLocalPart(email)
if slug == "" {
continue
}
parentSlug := ""
parent := csvValue(row, index, "상위 조직")
if start := strings.LastIndex(parent, "("); start >= 0 && strings.HasSuffix(parent, ")") {
parentSlug = worksmobileMailLocalPart(parent[start+1 : len(parent)-1])
}
result[slug] = worksmobileLiveCSVOrg{
Slug: slug,
Name: csvValue(row, index, "조직명"),
Email: email,
ParentSlug: parentSlug,
}
}
}
func requireWorksmobileLiveBaronCSVMatch(t *testing.T, tenants []domain.Tenant, csvNodes map[string]worksmobileLiveCSVOrg) {
t.Helper()
tenantSlugs := map[string]domain.Tenant{}
for _, tenant := range tenants {
tenantSlugs[tenant.Slug] = tenant
}
missingInBaron := make([]string, 0)
for slug := range csvNodes {
if _, ok := tenantSlugs[slug]; !ok {
missingInBaron = append(missingInBaron, slug)
}
}
missingInCSV := make([]string, 0)
for _, tenant := range tenants {
if _, ok := csvNodes[tenant.Slug]; !ok {
missingInCSV = append(missingInCSV, tenant.Slug)
}
}
sort.Strings(missingInBaron)
sort.Strings(missingInCSV)
require.Empty(t, missingInBaron, "CSV slugs missing in Baron")
require.Empty(t, missingInCSV, "Baron slugs missing in CSV")
}
func csvValue(row []string, index map[string]int, key string) string {
i, ok := index[key]
if !ok || i < 0 || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
func worksmobileLiveCSVPath(nodes map[string]worksmobileLiveCSVOrg, slug string) string {
node, ok := nodes[slug]
if !ok {
return slug
}
if node.ParentSlug == "" {
return node.Name
}
parentPath := worksmobileLiveCSVPath(nodes, node.ParentSlug)
if parentPath == "" {
return node.Name
}
return parentPath + "/" + node.Name
}
func findWorksmobileLiveRemoteByPath(groups []WorksmobileRemoteGroup, byID map[string]WorksmobileRemoteGroup, domainID int64, path string) (WorksmobileRemoteGroup, bool) {
var fallback WorksmobileRemoteGroup
found := false
for _, group := range groups {
if group.DomainID != domainID {
continue
}
if worksmobileLiveRemotePath(byID, group) != path {
continue
}
if group.ExternalID == "" {
return group, true
}
if !found {
fallback = group
found = true
}
}
return fallback, found
}
func worksmobileLiveRemotePath(byID map[string]WorksmobileRemoteGroup, group WorksmobileRemoteGroup) string {
names := []string{group.DisplayName}
parentID := strings.TrimSpace(group.ParentID)
seen := map[string]bool{group.ID: true}
for parentID != "" && !seen[parentID] {
parent, ok := byID[parentID]
if !ok {
break
}
seen[parentID] = true
if parent.DisplayName != "" {
names = append([]string{parent.DisplayName}, names...)
}
parentID = strings.TrimSpace(parent.ParentID)
}
return strings.Join(names, "/")
}
func collectWorksmobileLiveSubtreeIDs(rootID string, groups []WorksmobileRemoteGroup, target map[string]bool) {
if target[rootID] {
return
}
target[rootID] = true
for _, group := range groups {
if group.ParentID == rootID {
collectWorksmobileLiveSubtreeIDs(group.ID, groups, target)
}
}
}
func worksmobileLiveRemoteDepth(byID map[string]WorksmobileRemoteGroup, group WorksmobileRemoteGroup) int {
depth := 0
parentID := strings.TrimSpace(group.ParentID)
seen := map[string]bool{group.ID: true}
for parentID != "" && !seen[parentID] {
parent, ok := byID[parentID]
if !ok {
break
}
depth++
seen[parentID] = true
parentID = strings.TrimSpace(parent.ParentID)
}
return depth
}
func worksmobileLiveDSN() string { func worksmobileLiveDSN() string {
host := getenvDefault("DB_HOST", "localhost") host := getenvDefault("DB_HOST", "localhost")
port := getenvDefault("DB_PORT", "5432") port := getenvDefault("DB_PORT", "5432")

View File

@@ -21,6 +21,7 @@ const (
type WorksmobileOrgUnitPayload struct { type WorksmobileOrgUnitPayload struct {
DomainID int64 `json:"domainId"` DomainID int64 `json:"domainId"`
OrgUnitName string `json:"orgUnitName"` OrgUnitName string `json:"orgUnitName"`
Email string `json:"email,omitempty"`
OrgUnitExternalKey string `json:"orgUnitExternalKey"` OrgUnitExternalKey string `json:"orgUnitExternalKey"`
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"` ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
DisplayOrder int `json:"displayOrder"` DisplayOrder int `json:"displayOrder"`
@@ -78,6 +79,7 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
payload := WorksmobileOrgUnitPayload{ payload := WorksmobileOrgUnitPayload{
DomainID: domainID, DomainID: domainID,
OrgUnitName: strings.TrimSpace(tenant.Name), OrgUnitName: strings.TrimSpace(tenant.Name),
Email: buildWorksmobileOrgUnitEmail(tenant, domainTenant),
OrgUnitExternalKey: tenant.ID, OrgUnitExternalKey: tenant.ID,
DisplayOrder: displayOrder, DisplayOrder: displayOrder,
} }
@@ -90,6 +92,48 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
return payload, nil return payload, nil
} }
func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tenant) string {
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
if slug == "" {
return ""
}
if domainName := worksmobileTenantMailDomain(domainTenant); domainName != "" {
return slug + "@" + domainName
}
for _, candidate := range append([]domain.TenantDomain{}, domainTenant.Domains...) {
domainName := strings.ToLower(strings.TrimSpace(candidate.Domain))
if domainName != "" {
return slug + "@" + domainName
}
}
for _, candidate := range tenant.Domains {
domainName := strings.ToLower(strings.TrimSpace(candidate.Domain))
if domainName != "" {
return slug + "@" + domainName
}
}
return ""
}
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
return domainName
}
switch envKey {
case "SAMAN":
return "samaneng.com"
case "HANMAC":
return "hanmaceng.co.kr"
case "GPDTDC":
return "baroncs.co.kr"
case "BARONGROUP":
return "brsw.kr"
default:
return ""
}
}
func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) { func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig) return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig)
} }

View File

@@ -13,6 +13,7 @@ func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassifi
parentID := "11111111-1111-1111-1111-111111111111" parentID := "11111111-1111-1111-1111-111111111111"
tenant := domain.Tenant{ tenant := domain.Tenant{
ID: "22222222-2222-2222-2222-222222222222", ID: "22222222-2222-2222-2222-222222222222",
Slug: "tech-dev-center",
Name: "Saman Engineering", Name: "Saman Engineering",
ParentID: &parentID, ParentID: &parentID,
Domains: []domain.TenantDomain{ Domains: []domain.TenantDomain{
@@ -32,11 +33,29 @@ func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassifi
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID) require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "Saman Engineering", payload.OrgUnitName) require.Equal(t, "Saman Engineering", payload.OrgUnitName)
require.Equal(t, "tech-dev-center@samaneng.com", payload.Email)
require.Equal(t, tenant.ID, payload.OrgUnitExternalKey) require.Equal(t, tenant.ID, payload.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+parentID, payload.ParentOrgUnitID) require.Equal(t, "externalKey:"+parentID, payload.ParentOrgUnitID)
require.Equal(t, 7, payload.DisplayOrder) require.Equal(t, 7, payload.DisplayOrder)
} }
func TestBuildWorksmobileOrgUnitPayloadUsesWorksmobileMailDomainForBarongroup(t *testing.T) {
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tenant := domain.Tenant{
ID: "11111111-1111-1111-1111-111111111111",
Slug: "jangheon",
Name: "(주)장헌",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "jangheon.com"}},
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, tenant, nil, 1)
require.NoError(t, err)
require.Equal(t, int64(1004), payload.DomainID)
require.Equal(t, "jangheon@brsw.kr", payload.Email)
}
func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) { func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) {
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID} payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID}

View File

@@ -89,11 +89,7 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err return err
} }
err := w.client.CreateOrgUnit(ctx, payload) return w.client.UpsertOrgUnit(ctx, payload, stringValue(job.Payload["matchLocalPart"]))
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 {
return nil
}
return err
case domain.WorksmobileResourceUser: case domain.WorksmobileResourceUser:
switch job.Action { switch job.Action {
case domain.WorksmobileActionUpsert: case domain.WorksmobileActionUpsert:

View File

@@ -258,7 +258,10 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
ResourceID: tenant.ID, ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID, DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload}, Payload: domain.JSONMap{
"request": payload,
"matchLocalPart": tenant.Slug,
},
} }
if err := s.outboxRepo.Create(ctx, item); err != nil { if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err return nil, err
@@ -392,7 +395,10 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex
ResourceID: tenant.ID, ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID, DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload}, Payload: domain.JSONMap{
"request": payload,
"matchLocalPart": tenant.Slug,
},
}) })
} }
@@ -596,8 +602,7 @@ func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool {
func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant { func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
current := tenant current := tenant
for { for {
envKey := worksmobileTenantDomainIDEnvKey(current) if current.Type == domain.TenantTypeCompany || len(current.Domains) > 0 {
if envKey != "BARONGROUP_DOMAIN_ID" || current.Type == domain.TenantTypeCompany {
return current return current
} }
parentID := worksmobileTenantParentID(current) parentID := worksmobileTenantParentID(current)
@@ -635,8 +640,10 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
payload.ParentOrgUnitID = "" payload.ParentOrgUnitID = ""
} }
if tenant.ParentID != nil { if tenant.ParentID != nil {
if parent, ok := tenantByID[*tenant.ParentID]; ok && parent.Slug == "baron-group" { if parent, ok := tenantByID[*tenant.ParentID]; ok {
payload.ParentOrgUnitID = "" if parent.Slug == "baron-group" || !isWorksmobileOrgUnitTenant(parent, tenantByID) {
payload.ParentOrgUnitID = ""
}
} }
} }
return payload return payload
@@ -785,14 +792,27 @@ func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]dom
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem { func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteGroup{} remoteByExternalID := map[string]WorksmobileRemoteGroup{}
remoteByMailLocalPart := map[string]WorksmobileRemoteGroup{}
ambiguousMailLocalParts := map[string]bool{}
for _, remote := range remoteGroups { for _, remote := range remoteGroups {
if remote.ExternalID != "" { if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote 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) tenantByID := worksmobileTenantByID(localTenants)
localByID := map[string]domain.Tenant{} localByID := map[string]domain.Tenant{}
ignoredLocalByID := map[string]bool{} ignoredLocalByID := map[string]bool{}
matchedRemoteIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0) result := make([]WorksmobileComparisonItem, 0)
for _, tenant := range localTenants { for _, tenant := range localTenants {
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) { if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
@@ -801,7 +821,11 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
} }
localByID[tenant.ID] = tenant localByID[tenant.ID] = tenant
remote, matched := remoteByExternalID[tenant.ID] remote, matched := remoteByExternalID[tenant.ID]
if !matched {
remote, matched = remoteByMailLocalPart[worksmobileMailLocalPart(tenant.Slug)]
}
if matched && !includeMatched { if matched && !includeMatched {
matchedRemoteIDs[remote.ID] = true
continue continue
} }
item := WorksmobileComparisonItem{ item := WorksmobileComparisonItem{
@@ -817,20 +841,26 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
item.WorksmobileID = remote.ID item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName item.WorksmobileName = remote.DisplayName
item.WorksmobileEmail = remote.Email
item.WorksmobileDomainID = remote.DomainID item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName item.WorksmobileDomainName = remote.DomainName
item.WorksmobileParentID = remote.ParentID item.WorksmobileParentID = remote.ParentID
item.WorksmobileParentName = remote.ParentName item.WorksmobileParentName = remote.ParentName
matchedRemoteIDs[remote.ID] = true
} }
result = append(result, item) result = append(result, item)
} }
for _, remote := range remoteGroups { for _, remote := range remoteGroups {
if matchedRemoteIDs[remote.ID] {
continue
}
if remote.ExternalID == "" { if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{ result = append(result, WorksmobileComparisonItem{
ResourceType: "GROUP", ResourceType: "GROUP",
WorksmobileID: remote.ID, WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID, ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName, WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileDomainID: remote.DomainID, WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName, WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID, WorksmobileParentID: remote.ParentID,
@@ -848,6 +878,7 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
WorksmobileID: remote.ID, WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID, ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName, WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileDomainID: remote.DomainID, WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName, WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID, WorksmobileParentID: remote.ParentID,

View File

@@ -258,7 +258,43 @@ func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
require.Len(t, outboxRepo.created, 1) require.Len(t, outboxRepo.created, 1)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, organizationID, request.OrgUnitExternalKey) require.Equal(t, organizationID, request.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+companyID, request.ParentOrgUnitID) require.Empty(t, request.ParentOrgUnitID)
}
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "root-tenant"
companyID := "company-tenant"
organizationID := "organization-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Slug: "gpdtdc",
Name: "총괄기획&기술개발센터",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "baroncs.co.kr"}},
}
organization := domain.Tenant{
ID: organizationID,
Slug: "gpd",
Name: "총괄기획실",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
tenantByID := worksmobileTenantByID([]domain.Tenant{root, company, organization})
domainTenant := worksmobileDomainClassificationTenant(organization, tenantByID)
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(organization, domainTenant, nil, 1)
require.NoError(t, err)
require.Equal(t, companyID, domainTenant.ID)
require.Equal(t, int64(1003), payload.DomainID)
require.Equal(t, "gpd@baroncs.co.kr", payload.Email)
} }
func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) { func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) {

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
getHanmacFamilyTenantOrderRank,
orderHanmacFamilyChildren,
orderHanmacFamilyTenants,
} from "./hanmacFamilyOrder";
function tenant(name: string, slug: string) {
return { name, slug };
}
describe("hanmac family organization order", () => {
it("orders the top hanmac-family siblings by policy", () => {
const ordered = orderHanmacFamilyTenants([
tenant("바론그룹", "baron-group"),
tenant("한맥기술", "hanmac"),
tenant("삼안", "saman"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
]);
expect(ordered.map((item) => item.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
it("keeps hanmac-family as the root before ordered descendants", () => {
const family = tenant("한맥가족", "hanmac-family");
const children = orderHanmacFamilyChildren(family, [
tenant("바론그룹", "baron-group"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
tenant("삼안", "saman"),
tenant("한맥기술", "hanmac"),
]);
expect([family, ...children].map((item) => item.name)).toEqual([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
it("does not rank generic technical centers as GPDTDC", () => {
expect(
getHanmacFamilyTenantOrderRank(
tenant("기술개발센터", "rnd-center"),
),
).toBe(Number.MAX_SAFE_INTEGER);
});
});

View File

@@ -0,0 +1,65 @@
export type HanmacFamilyOrderTenant = {
name: string;
slug: string;
};
export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family";
export const HANMAC_FAMILY_TENANT_ORDER = [
"gpdtdc",
"saman",
"hanmac",
"baron-group",
] as const;
function normalizedTenantText(tenant: HanmacFamilyOrderTenant) {
return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
}
export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) {
return (
tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
tenant.name.includes("한맥가족")
);
}
export function getHanmacFamilyTenantOrderRank(
tenant: HanmacFamilyOrderTenant,
) {
const text = normalizedTenantText(tenant);
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
if (text.includes("saman") || text.includes("삼안")) return 1;
if (
(text.includes("hanmac") || text.includes("한맥기술")) &&
!isHanmacFamilyRootTenant(tenant)
) {
return 2;
}
if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
return Number.MAX_SAFE_INTEGER;
}
export function compareHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
a: T,
b: T,
) {
const rankDiff =
getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
if (rankDiff !== 0) return rankDiff;
return a.name.localeCompare(b.name);
}
export function orderHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
tenants: readonly T[],
) {
return [...tenants].sort(compareHanmacFamilyTenants);
}
export function orderHanmacFamilyChildren<T extends HanmacFamilyOrderTenant>(
parent: HanmacFamilyOrderTenant,
children: readonly T[],
) {
return isHanmacFamilyRootTenant(parent)
? orderHanmacFamilyTenants(children)
: [...children];
}

View File

@@ -51,6 +51,40 @@ describe("buildOrgPickerTree", () => {
]); ]);
}); });
it("orders hanmac-family children by the shared organization policy", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant(
"baron-group-id",
"COMPANY_GROUP",
"바론그룹",
"baron-group",
"hanmac-family-id",
),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant(
"gpdtdc-id",
"ORGANIZATION",
"총괄기획&기술개발센터",
"gpdtdc",
"hanmac-family-id",
),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
it("scopes descendant filtering by tenant slug", () => { it("scopes descendant filtering by tenant slug", () => {
const tenants = [ const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"), tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),

View File

@@ -1,5 +1,6 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi"; import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree"; import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
import { orderHanmacFamilyChildren } from "./hanmacFamilyOrder";
import type { OrgPickerTreeNode } from "./pickerTypes"; import type { OrgPickerTreeNode } from "./pickerTypes";
import { filterTenantsByVisibility } from "./tenantVisibility"; import { filterTenantsByVisibility } from "./tenantVisibility";
import { getOrgChartUserDisplayName } from "./userDisplay"; import { getOrgChartUserDisplayName } from "./userDisplay";
@@ -50,9 +51,10 @@ function tenantToPickerNode(
tenant: TenantNode, tenant: TenantNode,
usersBySlug: Map<string, UserSummary[]>, usersBySlug: Map<string, UserSummary[]>,
): OrgPickerTreeNode { ): OrgPickerTreeNode {
const tenantChildren = tenant.children.map((child) => const tenantChildren = orderHanmacFamilyChildren(
tenantToPickerNode(child, usersBySlug), tenant,
); tenant.children,
).map((child) => tenantToPickerNode(child, usersBySlug));
const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map( const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map(
(user) => ({ (user) => ({
type: "user" as const, type: "user" as const,
@@ -150,9 +152,10 @@ export function buildOrgPickerTree({
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" }; if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
const companies = groupNode.children.filter( const companies = orderHanmacFamilyChildren(
(node) => node.type === "COMPANY", groupNode,
); groupNode.children,
).filter((node) => node.type === "COMPANY");
const scopedRoot = tenantId const scopedRoot = tenantId
? findTenantNode([groupNode], tenantId) ? findTenantNode([groupNode], tenantId)
: groupNode; : groupNode;

View File

@@ -10,6 +10,10 @@ import {
fetchUsers, fetchUsers,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import {
orderHanmacFamilyChildren,
orderHanmacFamilyTenants,
} from "../hanmacFamilyOrder";
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility"; import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay"; import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
@@ -565,7 +569,10 @@ function buildOrgNode(
? 0 ? 0
: inheritedCompanyColorDepth + 1; : inheritedCompanyColorDepth + 1;
const members = usersMap.get(slug) || []; const members = usersMap.get(slug) || [];
const children = tenantNode.children.map((child) => const children = orderHanmacFamilyChildren(
tenantNode,
tenantNode.children,
).map((child) =>
buildOrgNode( buildOrgNode(
child, child,
usersMap, usersMap,
@@ -1018,33 +1025,14 @@ function collectOrgSelectionDescendants(
]); ]);
} }
function getOrgSelectionPolicyRank(node: TenantNode) {
const text = `${node.slug} ${node.name}`.toLowerCase();
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 1;
if (text.includes("saman") || text.includes("삼안")) return 2;
if (
(text.includes("hanmac") || text.includes("한맥기술")) &&
!text.includes("hanmac-family")
) {
return 3;
}
if (text.includes("baron") || text.includes("바론")) return 4;
return 100;
}
export function buildOrgSelectionOptions( export function buildOrgSelectionOptions(
familyRoot: TenantNode | null, familyRoot: TenantNode | null,
): OrgSelectionOption[] { ): OrgSelectionOption[] {
return (familyRoot?.children ?? []) return orderHanmacFamilyTenants(
.filter((node) => (familyRoot?.children ?? []).filter((node) =>
["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(node.type), ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(node.type),
) ),
.sort((a, b) => { )
const rankDiff =
getOrgSelectionPolicyRank(a) - getOrgSelectionPolicyRank(b);
if (rankDiff !== 0) return rankDiff;
return a.name.localeCompare(b.name);
})
.map((node) => ({ .map((node) => ({
descendants: collectOrgSelectionDescendants(node, 2), descendants: collectOrgSelectionDescendants(node, 2),
id: node.id, id: node.id,

View File

@@ -303,6 +303,68 @@ test("picker defaults to the hanmac-family company-group when no tenant id is su
await expect(picker.getByText("Wrong Group", { exact: true })).toHaveCount(0); await expect(picker.getByText("Wrong Group", { exact: true })).toHaveCount(0);
}); });
test("embed preview picker orders hanmac-family tenants by the shared policy", async ({
page,
}) => {
await page.unroute("**/api/v1/admin/tenants**");
await page.unroute("**/api/v1/admin/users**");
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant(
"baron-group-id",
"COMPANY_GROUP",
"바론그룹",
"baron-group",
"hanmac-family-id",
),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant(
"gpdtdc-id",
"ORGANIZATION",
"총괄기획&기술개발센터",
"gpdtdc",
"hanmac-family-id",
),
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: tenants,
total: tenants.length,
limit: 10000,
offset: 0,
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [],
total: 0,
limit: 5000,
offset: 0,
}),
});
});
await page.goto(withShareToken("/embed-preview?select=tenant"));
await expect(
page.frameLocator("iframe").getByTestId("org-picker-node-name-tenant"),
).toHaveText([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
test("picker displays user names with grade and optional position", async ({ test("picker displays user names with grade and optional position", async ({
page, page,
}) => { }) => {