forked from baron/baron-sso
동기화 기초구조 마련
This commit is contained in:
@@ -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)"
|
||||||
|
|||||||
|
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"임원직속",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = ¤tCopy
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
54
orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
Normal file
54
orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
orgfront/src/features/orgchart/hanmacFamilyOrder.ts
Normal file
65
orgfront/src/features/orgchart/hanmacFamilyOrder.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user