forked from baron/baron-sso
feat: improve Worksmobile tenant sync handling
This commit is contained in:
7
adminfront/public/LINE_WORKS_Appicon.svg
Normal file
7
adminfront/public/LINE_WORKS_Appicon.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M23.5556 0H6.44444C2.88528 0 0 2.88528 0 6.44444V23.5556C0 27.1147 2.88528 30 6.44444 30H23.5556C27.1147 30 30 27.1147 30 23.5556V6.44444C30 2.88528 27.1147 0 23.5556 0Z" fill="white"/>
|
||||||
|
<path d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z" fill="#028B3A"/>
|
||||||
|
<path d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z" fill="#88E518"/>
|
||||||
|
<path d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z" fill="#7EE3A1"/>
|
||||||
|
<path d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z" fill="#03C75A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,11 +1,12 @@
|
|||||||
id,name,type,parent_tenant_slug,slug,memo,email_domain
|
id,name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync
|
||||||
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
|
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,,,,
|
||||||
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com
|
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr,,,
|
||||||
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr
|
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com,,,
|
||||||
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr
|
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr,,,
|
||||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,
|
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,brsw.kr,,,
|
||||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com
|
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,hanmac-family,halla,네이버웍스 한라 HALLA_DOMAIN_ID,hallasanup.com,,,
|
||||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr
|
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com,,,
|
||||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr
|
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
|
||||||
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr
|
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
|
||||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
|
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
|
||||||
|
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
|
||||||
|
|||||||
|
@@ -48,6 +48,7 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
|
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||||
{
|
{
|
||||||
path: "tenants/:tenantId",
|
path: "tenants/:tenantId",
|
||||||
element: <TenantDetailPage />,
|
element: <TenantDetailPage />,
|
||||||
@@ -56,7 +57,6 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function renderLayout(entry = "/users") {
|
|||||||
path="tenants/:tenantId"
|
path="tenants/:tenantId"
|
||||||
element={<div>Tenant outlet</div>}
|
element={<div>Tenant outlet</div>}
|
||||||
/>
|
/>
|
||||||
|
<Route path="worksmobile" element={<div>Worksmobile outlet</div>} />
|
||||||
<Route path="login" element={<div>Login outlet</div>} />
|
<Route path="login" element={<div>Login outlet</div>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -99,7 +100,28 @@ describe("admin AppLayout", () => {
|
|||||||
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("User Projection")).not.toBeInTheDocument();
|
||||||
|
const navigation = screen.getByRole("navigation");
|
||||||
|
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
|
||||||
|
link.textContent?.trim(),
|
||||||
|
);
|
||||||
|
expect(navLabels).toEqual([
|
||||||
|
"Overview",
|
||||||
|
"Tenants",
|
||||||
|
"Worksmobile",
|
||||||
|
"Users",
|
||||||
|
"Data Integrity",
|
||||||
|
"Auth Guard",
|
||||||
|
"API Keys",
|
||||||
|
"Audit Logs",
|
||||||
|
]);
|
||||||
|
const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon");
|
||||||
|
expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg");
|
||||||
|
expect(worksmobileIcon).toHaveAttribute("fill", "none");
|
||||||
|
expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4);
|
||||||
|
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Database,
|
|
||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Network,
|
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
@@ -32,7 +30,7 @@ import {
|
|||||||
shellLayoutClasses,
|
shellLayoutClasses,
|
||||||
writeShellSessionExpiryEnabled,
|
writeShellSessionExpiryEnabled,
|
||||||
} from "../../../../common/shell";
|
} from "../../../../common/shell";
|
||||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
import { debugLog } from "../../lib/debugLog";
|
import { debugLog } from "../../lib/debugLog";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
@@ -61,6 +59,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
|||||||
to: "/users",
|
to: "/users",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.admin.nav.auth_guard",
|
||||||
|
labelFallback: "Auth Guard",
|
||||||
|
to: "/auth",
|
||||||
|
icon: KeyRound,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.admin.nav.api_keys",
|
labelKey: "ui.admin.nav.api_keys",
|
||||||
labelFallback: "API Keys",
|
labelFallback: "API Keys",
|
||||||
@@ -73,12 +77,6 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
|||||||
to: "/audit-logs",
|
to: "/audit-logs",
|
||||||
icon: NotebookTabs,
|
icon: NotebookTabs,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
labelKey: "ui.admin.nav.auth_guard",
|
|
||||||
labelFallback: "Auth Guard",
|
|
||||||
to: "/auth",
|
|
||||||
icon: KeyRound,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type SessionStatusProps = {
|
type SessionStatusProps = {
|
||||||
@@ -123,6 +121,38 @@ function SessionStatusText(props: SessionStatusProps) {
|
|||||||
return <>{sessionStatus.text}</>;
|
return <>{sessionStatus.text}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LineWorksNavIcon({ size = 18 }: { size?: number | string }) {
|
||||||
|
const iconSize = typeof size === "number" ? size : Number.parseFloat(size);
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="worksmobile-nav-icon"
|
||||||
|
width={Number.isFinite(iconSize) ? iconSize : size}
|
||||||
|
height={Number.isFinite(iconSize) ? iconSize : size}
|
||||||
|
viewBox="0 0 30 30"
|
||||||
|
fill="none"
|
||||||
|
className="shrink-0 text-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -178,11 +208,10 @@ function AppLayout() {
|
|||||||
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
||||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
const showWorksmobile = canAccessWorksmobile({
|
||||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
...profile,
|
||||||
{ includeInternal: true },
|
role: effectiveRole ?? profile?.role,
|
||||||
);
|
});
|
||||||
|
|
||||||
const filteredItems = items.filter((item) => {
|
const filteredItems = items.filter((item) => {
|
||||||
if (isTest) return true;
|
if (isTest) return true;
|
||||||
if (item.to === "/api-keys") return isSuperAdmin;
|
if (item.to === "/api-keys") return isSuperAdmin;
|
||||||
@@ -196,20 +225,15 @@ function AppLayout() {
|
|||||||
to: "/tenants",
|
to: "/tenants",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
if (showWorksmobile) {
|
||||||
labelKey: "ui.admin.nav.org_chart",
|
filteredItems.splice(2, 0, {
|
||||||
labelFallback: "Org Chart",
|
labelKey: "ui.admin.nav.worksmobile",
|
||||||
to: orgfrontUrl,
|
labelFallback: "Worksmobile",
|
||||||
icon: Network,
|
to: "/worksmobile",
|
||||||
isExternal: true,
|
icon: LineWorksNavIcon,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
filteredItems.splice(4, 0, {
|
filteredItems.splice(4, 0, {
|
||||||
labelKey: "ui.admin.nav.user_projection",
|
|
||||||
labelFallback: "User Projection",
|
|
||||||
to: "/system/projections/users",
|
|
||||||
icon: Database,
|
|
||||||
});
|
|
||||||
filteredItems.splice(5, 0, {
|
|
||||||
labelKey: "ui.admin.nav.data_integrity",
|
labelKey: "ui.admin.nav.data_integrity",
|
||||||
labelFallback: "Data Integrity",
|
labelFallback: "Data Integrity",
|
||||||
to: "/system/data-integrity",
|
to: "/system/data-integrity",
|
||||||
@@ -231,26 +255,14 @@ function AppLayout() {
|
|||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
filteredItems.splice(
|
if (showWorksmobile) {
|
||||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
filteredItems.splice(2, 0, {
|
||||||
0,
|
labelKey: "ui.admin.nav.worksmobile",
|
||||||
{
|
labelFallback: "Worksmobile",
|
||||||
labelKey: "ui.admin.nav.org_chart",
|
to: "/worksmobile",
|
||||||
labelFallback: "Org Chart",
|
icon: LineWorksNavIcon,
|
||||||
to: orgfrontUrl,
|
});
|
||||||
icon: Network,
|
}
|
||||||
isExternal: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
|
||||||
filteredItems.splice(1, 0, {
|
|
||||||
labelKey: "ui.admin.nav.org_chart",
|
|
||||||
labelFallback: "Org Chart",
|
|
||||||
to: orgfrontUrl,
|
|
||||||
icon: Network,
|
|
||||||
isExternal: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredItems;
|
return filteredItems;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
fetchDataIntegrityReport,
|
fetchDataIntegrityReport,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchOrphanUserLoginIDs,
|
fetchOrphanUserLoginIDs,
|
||||||
|
fetchUserProjectionStatus,
|
||||||
|
reconcileUserProjection,
|
||||||
|
resetUserProjection,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { createI18nMock } from "../../test/i18nMock";
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
import DataIntegrityPage from "./DataIntegrityPage";
|
import DataIntegrityPage from "./DataIntegrityPage";
|
||||||
@@ -60,6 +63,24 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
],
|
],
|
||||||
total: 1,
|
total: 1,
|
||||||
})),
|
})),
|
||||||
|
fetchUserProjectionStatus: vi.fn(async () => ({
|
||||||
|
name: "kratos_users",
|
||||||
|
status: "ready",
|
||||||
|
ready: true,
|
||||||
|
lastSyncedAt: "2026-05-11T03:00:00Z",
|
||||||
|
updatedAt: "2026-05-11T03:00:10Z",
|
||||||
|
projectedUsers: 152,
|
||||||
|
})),
|
||||||
|
reconcileUserProjection: vi.fn(async () => ({
|
||||||
|
status: "success",
|
||||||
|
syncedUsers: 152,
|
||||||
|
updatedAt: "2026-05-11T03:01:00Z",
|
||||||
|
})),
|
||||||
|
resetUserProjection: vi.fn(async () => ({
|
||||||
|
status: "success",
|
||||||
|
syncedUsers: 152,
|
||||||
|
updatedAt: "2026-05-11T03:02:00Z",
|
||||||
|
})),
|
||||||
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||||
deletedCount: 1,
|
deletedCount: 1,
|
||||||
deleted: [
|
deleted: [
|
||||||
@@ -95,6 +116,7 @@ describe("DataIntegrityPage", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRole = "super_admin";
|
currentRole = "super_admin";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
window.localStorage.setItem("locale", "ko");
|
window.localStorage.setItem("locale", "ko");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,6 +124,12 @@ describe("DataIntegrityPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("tab", { name: "정합성 검사" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("tab", { name: "사용자 동기화" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||||
@@ -113,6 +141,28 @@ describe("DataIntegrityPage", () => {
|
|||||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders user projection sync inside data integrity", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("준비됨")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("152")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(resetUserProjection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(fetchUserProjectionStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows orphan login ID targets and deletes selected rows", async () => {
|
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { getAdminDateLocale } from "../../lib/locale";
|
import { getAdminDateLocale } from "../../lib/locale";
|
||||||
|
import { UserProjectionContent } from "../projections/UserProjectionPage";
|
||||||
|
|
||||||
function statusLabel(status: DataIntegrityStatus) {
|
function statusLabel(status: DataIntegrityStatus) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -187,6 +188,14 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageTabClassName(active: boolean) {
|
||||||
|
return `relative px-6 py-3 text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
function OrphanLoginIDTable({
|
function OrphanLoginIDTable({
|
||||||
items,
|
items,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -284,6 +293,9 @@ function OrphanLoginIDTable({
|
|||||||
|
|
||||||
function DataIntegrityContent() {
|
function DataIntegrityContent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
|
||||||
|
"integrity",
|
||||||
|
);
|
||||||
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
||||||
const [recheckStatus, setRecheckStatus] = useState<
|
const [recheckStatus, setRecheckStatus] = useState<
|
||||||
"idle" | "running" | "success" | "error"
|
"idle" | "running" | "success" | "error"
|
||||||
@@ -360,210 +372,243 @@ function DataIntegrityContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1">
|
{activeTab === "integrity" ? (
|
||||||
<Button
|
<div className="flex flex-col items-end gap-1">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRecheck}
|
|
||||||
disabled={isLoading || isFetching || isManualRechecking}
|
|
||||||
>
|
|
||||||
<Database size={16} />
|
|
||||||
{isManualRechecking
|
|
||||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
|
||||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
|
||||||
</Button>
|
|
||||||
{recheckMessage ? (
|
|
||||||
<output
|
|
||||||
aria-live="polite"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{recheckMessage}
|
|
||||||
</output>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-4 pb-6">
|
|
||||||
{isError ? (
|
|
||||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
|
||||||
{(error as Error)?.message ||
|
|
||||||
t(
|
|
||||||
"msg.admin.integrity.report.load_error",
|
|
||||||
"정합성 리포트를 불러오지 못했습니다.",
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-border bg-card p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
{t(
|
|
||||||
"ui.admin.integrity.read_model.title",
|
|
||||||
"Read model integrity",
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.admin.integrity.read_model.description",
|
|
||||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{data ? (
|
|
||||||
<Badge variant={statusBadgeVariant(data.status)}>
|
|
||||||
{statusLabel(data.status)}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-8 text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
|
||||||
{data?.summary.totalChecks ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
|
||||||
{data?.summary.passed ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
|
||||||
{data?.summary.failures ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm">
|
|
||||||
{formatDateTime(data?.checkedAt)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{(data?.sections ?? []).map((section) => (
|
|
||||||
<section
|
|
||||||
key={section.key}
|
|
||||||
className="rounded-lg border border-border bg-card p-5"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
{integritySectionLabel(section.key, section.label)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{integritySectionDescription(section.key)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={statusBadgeVariant(section.status)}>
|
|
||||||
{statusLabel(section.status)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{section.checks.map((check) => (
|
|
||||||
<div
|
|
||||||
key={check.key}
|
|
||||||
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<CheckIcon check={check} />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{integrityCheckLabel(check.key, check.label)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{integrityCheckDescription(
|
|
||||||
check.key,
|
|
||||||
check.description,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 md:justify-end">
|
|
||||||
<Badge variant={statusBadgeVariant(check.status)}>
|
|
||||||
{statusLabel(check.status)}
|
|
||||||
</Badge>
|
|
||||||
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
|
||||||
{check.count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-border bg-card p-5">
|
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
{t(
|
|
||||||
"ui.admin.integrity.orphan_login_ids.title",
|
|
||||||
"유령 로그인 ID 정리",
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.admin.integrity.orphan_login_ids.description",
|
|
||||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="outline"
|
||||||
onClick={handleDeleteSelected}
|
onClick={handleRecheck}
|
||||||
disabled={
|
disabled={isLoading || isFetching || isManualRechecking}
|
||||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
<Database size={16} />
|
||||||
|
{isManualRechecking
|
||||||
|
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||||
|
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{recheckMessage ? (
|
||||||
|
<output
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{recheckMessage}
|
||||||
|
</output>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{orphanLoginIDsQuery.isError ? (
|
) : null}
|
||||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
</header>
|
||||||
{t(
|
|
||||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
<div
|
||||||
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
className="flex border-b border-border"
|
||||||
)}
|
role="tablist"
|
||||||
</div>
|
aria-label="데이터 정합성 탭"
|
||||||
) : null}
|
>
|
||||||
{deleteMutation.data ? (
|
<button
|
||||||
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
type="button"
|
||||||
{t(
|
role="tab"
|
||||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
aria-selected={activeTab === "integrity"}
|
||||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
className={pageTabClassName(activeTab === "integrity")}
|
||||||
{ count: deleteMutation.data.deletedCount },
|
onClick={() => setActiveTab("integrity")}
|
||||||
)}
|
>
|
||||||
</div>
|
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
|
||||||
) : null}
|
</button>
|
||||||
<OrphanLoginIDTable
|
<button
|
||||||
items={orphanItems}
|
type="button"
|
||||||
selectedIds={selectedOrphanIds}
|
role="tab"
|
||||||
onToggle={toggleOrphanID}
|
aria-selected={activeTab === "projection"}
|
||||||
/>
|
className={pageTabClassName(activeTab === "projection")}
|
||||||
</section>
|
onClick={() => setActiveTab("projection")}
|
||||||
|
>
|
||||||
|
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "integrity" ? (
|
||||||
|
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
|
||||||
|
{isError ? (
|
||||||
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
{(error as Error)?.message ||
|
||||||
|
t(
|
||||||
|
"msg.admin.integrity.report.load_error",
|
||||||
|
"정합성 리포트를 불러오지 못했습니다.",
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.read_model.title",
|
||||||
|
"Read model integrity",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.read_model.description",
|
||||||
|
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data ? (
|
||||||
|
<Badge variant={statusBadgeVariant(data.status)}>
|
||||||
|
{statusLabel(data.status)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-8 text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.totalChecks ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.passed ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.failures ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm">
|
||||||
|
{formatDateTime(data?.checkedAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(data?.sections ?? []).map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.key}
|
||||||
|
className="rounded-lg border border-border bg-card p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{integritySectionLabel(section.key, section.label)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{integritySectionDescription(section.key)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadgeVariant(section.status)}>
|
||||||
|
{statusLabel(section.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{section.checks.map((check) => (
|
||||||
|
<div
|
||||||
|
key={check.key}
|
||||||
|
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<CheckIcon check={check} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{integrityCheckLabel(check.key, check.label)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{integrityCheckDescription(
|
||||||
|
check.key,
|
||||||
|
check.description,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 md:justify-end">
|
||||||
|
<Badge variant={statusBadgeVariant(check.status)}>
|
||||||
|
{statusLabel(check.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||||
|
{check.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.orphan_login_ids.title",
|
||||||
|
"유령 로그인 ID 정리",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.description",
|
||||||
|
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={
|
||||||
|
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{orphanLoginIDsQuery.isError ? (
|
||||||
|
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||||
|
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{deleteMutation.data ? (
|
||||||
|
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||||
|
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||||
|
{ count: deleteMutation.data.deletedCount },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<OrphanLoginIDTable
|
||||||
|
items={orphanItems}
|
||||||
|
selectedIds={selectedOrphanIds}
|
||||||
|
onToggle={toggleOrphanID}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="animate-in fade-in duration-500">
|
||||||
|
<UserProjectionContent embedded />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ function ProjectionStatusBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserProjectionContent() {
|
export function UserProjectionContent({
|
||||||
|
embedded = false,
|
||||||
|
}: {
|
||||||
|
embedded?: boolean;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["user-projection-status"],
|
queryKey: ["user-projection-status"],
|
||||||
@@ -94,50 +98,55 @@ function UserProjectionContent() {
|
|||||||
const actionResult = reconcileMutation.data ?? resetMutation.data;
|
const actionResult = reconcileMutation.data ?? resetMutation.data;
|
||||||
const actionError = reconcileMutation.error ?? resetMutation.error;
|
const actionError = reconcileMutation.error ?? resetMutation.error;
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<header
|
||||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
className={
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
embedded
|
||||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
|
||||||
<Users size={20} />
|
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
|
||||||
</div>
|
}
|
||||||
<div className="space-y-2">
|
>
|
||||||
<h2 className="text-3xl font-semibold">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
{t(
|
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||||
"ui.admin.user_projection.title",
|
<Users size={20} />
|
||||||
"User Projection Management",
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.admin.user_projection.subtitle",
|
|
||||||
"Review and sync the Kratos user read model.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<h2 className="text-3xl font-semibold">
|
||||||
type="button"
|
{t("ui.admin.user_projection.title", "User Projection Management")}
|
||||||
variant="outline"
|
</h2>
|
||||||
onClick={() => reconcileMutation.mutate()}
|
<p className="text-sm text-muted-foreground">
|
||||||
disabled={isWorking}
|
{t(
|
||||||
>
|
"msg.admin.user_projection.subtitle",
|
||||||
<RefreshCw size={16} />
|
"Review and sync the Kratos user read model.",
|
||||||
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
)}
|
||||||
</Button>
|
</p>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={isWorking}
|
|
||||||
>
|
|
||||||
<RotateCcw size={16} />
|
|
||||||
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => reconcileMutation.mutate()}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
{(error as Error)?.message ||
|
{(error as Error)?.message ||
|
||||||
@@ -243,6 +252,22 @@ function UserProjectionContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-6">
|
||||||
|
{header}
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||||
|
{header}
|
||||||
|
{body}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type ParentTenantSelectorProps = {
|
|||||||
orgChartPickerLabel?: string;
|
orgChartPickerLabel?: string;
|
||||||
localPickerLabel?: string;
|
localPickerLabel?: string;
|
||||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
controlTestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ParentTenantSelector({
|
export function ParentTenantSelector({
|
||||||
@@ -49,6 +51,8 @@ export function ParentTenantSelector({
|
|||||||
orgChartPickerLabel,
|
orgChartPickerLabel,
|
||||||
localPickerLabel,
|
localPickerLabel,
|
||||||
localTenantFilter,
|
localTenantFilter,
|
||||||
|
compact = false,
|
||||||
|
controlTestId,
|
||||||
}: ParentTenantSelectorProps) {
|
}: ParentTenantSelectorProps) {
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||||
@@ -81,19 +85,37 @@ export function ParentTenantSelector({
|
|||||||
}, [excludeTenantId, onChange, pickerOpen]);
|
}, [excludeTenantId, onChange, pickerOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className={compact ? "space-y-1" : "space-y-2"}>
|
||||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
|
||||||
|
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Label className="text-sm font-semibold">{label}</Label>
|
<Label className="text-sm font-semibold">{label}</Label>
|
||||||
{labelAction}
|
{labelAction}
|
||||||
</div>
|
</div>
|
||||||
<input id={id} name={id} type="hidden" value={value} readOnly />
|
<input id={id} name={id} type="hidden" value={value} readOnly />
|
||||||
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
|
<div
|
||||||
|
data-testid={controlTestId}
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
|
||||||
|
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button type="button" variant="outline" size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||||
|
>
|
||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
{orgChartPickerLabel ??
|
{orgChartPickerLabel ??
|
||||||
selectedTenant?.name ??
|
(compact ? undefined : selectedTenant?.name) ??
|
||||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -185,14 +207,23 @@ export function ParentTenantSelector({
|
|||||||
)}
|
)}
|
||||||
{selectedTenant ? (
|
{selectedTenant ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span
|
||||||
{selectedTenant.slug} · {selectedTenant.type}
|
className={
|
||||||
|
compact
|
||||||
|
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||||
|
: "text-xs text-muted-foreground"
|
||||||
|
}
|
||||||
|
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
|
||||||
|
>
|
||||||
|
{compact
|
||||||
|
? `${selectedTenant.name} · ${selectedTenant.slug}`
|
||||||
|
: `${selectedTenant.slug} · ${selectedTenant.type}`}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||||
onClick={() => onChange("")}
|
onClick={() => onChange("")}
|
||||||
aria-label={noneLabel}
|
aria-label={noneLabel}
|
||||||
>
|
>
|
||||||
@@ -200,7 +231,15 @@ export function ParentTenantSelector({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">{noneLabel}</span>
|
<span
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||||
|
: "text-xs text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{noneLabel}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{contextLabel && (
|
{contextLabel && (
|
||||||
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
|
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Building2, Sparkles } from "lucide-react";
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -46,6 +47,7 @@ function TenantCreatePage() {
|
|||||||
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
|
||||||
const [orgUnitType, setOrgUnitType] = useState("");
|
const [orgUnitType, setOrgUnitType] = useState("");
|
||||||
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
const [visibility, setVisibility] = useState<TenantVisibility>("public");
|
||||||
|
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
|
||||||
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[]>([]);
|
||||||
@@ -109,7 +111,11 @@ function TenantCreatePage() {
|
|||||||
status,
|
status,
|
||||||
domains,
|
domains,
|
||||||
config: canConfigureHanmacOrg
|
config: canConfigureHanmacOrg
|
||||||
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
|
? mergeTenantOrgConfig(undefined, {
|
||||||
|
orgUnitType,
|
||||||
|
visibility,
|
||||||
|
worksmobileExcluded,
|
||||||
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||||
}),
|
}),
|
||||||
@@ -284,6 +290,27 @@ function TenantCreatePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="tenant-worksmobile-excluded-slot"
|
||||||
|
className="flex min-h-9 items-center gap-2 rounded-md border border-input px-3 py-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="worksmobileExcluded"
|
||||||
|
checked={worksmobileExcluded}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setWorksmobileExcluded(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="worksmobileExcluded"
|
||||||
|
className="cursor-pointer text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.profile.worksmobile_excluded",
|
||||||
|
"WORKS 연동 제외",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export function canShowWorksmobileEntry(tenant?: {
|
|
||||||
id?: string;
|
|
||||||
slug?: string;
|
|
||||||
parentId?: string | null;
|
|
||||||
}) {
|
|
||||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
|
||||||
|
|
||||||
describe("TenantDetailPage Worksmobile entry visibility", () => {
|
|
||||||
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
|
|
||||||
expect(
|
|
||||||
canShowWorksmobileEntry({
|
|
||||||
id: "hanmac-family-id",
|
|
||||||
slug: "hanmac-family",
|
|
||||||
parentId: undefined,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
canShowWorksmobileEntry({
|
|
||||||
id: "hanmac-child-id",
|
|
||||||
slug: "hanmac-family",
|
|
||||||
parentId: "root-id",
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
canShowWorksmobileEntry({
|
|
||||||
id: "other-id",
|
|
||||||
slug: "other",
|
|
||||||
parentId: undefined,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,6 @@ 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";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
|
|
||||||
|
|
||||||
function TenantDetailPage() {
|
function TenantDetailPage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
@@ -26,8 +25,6 @@ function TenantDetailPage() {
|
|||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
const canAccessSchema =
|
const canAccessSchema =
|
||||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
|
||||||
|
|
||||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||||
const isOrganizationTab = location.pathname.includes("/organization");
|
const isOrganizationTab = location.pathname.includes("/organization");
|
||||||
const isWorksmobileTab = location.pathname.includes("/worksmobile");
|
const isWorksmobileTab = location.pathname.includes("/worksmobile");
|
||||||
@@ -125,18 +122,6 @@ function TenantDetailPage() {
|
|||||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{showWorksmobileEntry && (
|
|
||||||
<Link
|
|
||||||
to={`/tenants/${tenantId}/worksmobile`}
|
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
|
||||||
isWorksmobileTab
|
|
||||||
? "text-primary border-b-2 border-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet for nested routes */}
|
{/* Outlet for nested routes */}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ function renderTenantDetailPage() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
|
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
|
||||||
<Route index element={<div>profile</div>} />
|
<Route index element={<div>profile</div>} />
|
||||||
<Route path="worksmobile" element={<div>worksmobile</div>} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -42,16 +41,11 @@ describe("TenantDetailPage Worksmobile navigation", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens Worksmobile management in the current admin route", async () => {
|
it("does not render Worksmobile as a tenant detail tab", async () => {
|
||||||
renderTenantDetailPage();
|
renderTenantDetailPage();
|
||||||
|
|
||||||
const link = await screen.findByRole("link", { name: /Worksmobile/i });
|
await screen.findByText("프로필");
|
||||||
|
|
||||||
expect(link).toHaveAttribute(
|
expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull();
|
||||||
"href",
|
|
||||||
"/tenants/hanmac-family-id/worksmobile",
|
|
||||||
);
|
|
||||||
expect(link).not.toHaveAttribute("target");
|
|
||||||
expect(link).not.toHaveAttribute("rel");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ import {
|
|||||||
} from "./tenantListView";
|
} from "./tenantListView";
|
||||||
|
|
||||||
const tenantCSVTemplate =
|
const tenantCSVTemplate =
|
||||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n";
|
||||||
const tenantPageSize = 500;
|
const tenantPageSize = 500;
|
||||||
const _tenantVirtualizationThreshold = 250;
|
const _tenantVirtualizationThreshold = 250;
|
||||||
const _tenantEstimatedRowHeight = 73;
|
const _tenantEstimatedRowHeight = 73;
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import {
|
|||||||
type ServerDomainConflict,
|
type ServerDomainConflict,
|
||||||
} from "../utils/domainTags";
|
} from "../utils/domainTags";
|
||||||
import {
|
import {
|
||||||
|
getOrgUnitTypeOptionsForTenantType,
|
||||||
mergeTenantOrgConfig,
|
mergeTenantOrgConfig,
|
||||||
ORG_UNIT_TYPE_OPTIONS,
|
|
||||||
readTenantOrgConfig,
|
readTenantOrgConfig,
|
||||||
removeTenantOrgConfig,
|
removeTenantOrgConfig,
|
||||||
shouldAllowHanmacOrgConfig,
|
shouldAllowHanmacOrgConfig,
|
||||||
@@ -70,6 +70,7 @@ export function TenantProfilePage() {
|
|||||||
const [orgUnitType, setOrgUnitType] = useState("");
|
const [orgUnitType, setOrgUnitType] = useState("");
|
||||||
const [tenantVisibility, setTenantVisibility] =
|
const [tenantVisibility, setTenantVisibility] =
|
||||||
useState<TenantVisibility>("public");
|
useState<TenantVisibility>("public");
|
||||||
|
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantQuery.data) {
|
if (tenantQuery.data) {
|
||||||
@@ -84,6 +85,7 @@ export function TenantProfilePage() {
|
|||||||
setParentId(tenantQuery.data.parentId ?? "");
|
setParentId(tenantQuery.data.parentId ?? "");
|
||||||
setOrgUnitType(orgConfig.orgUnitType);
|
setOrgUnitType(orgConfig.orgUnitType);
|
||||||
setTenantVisibility(orgConfig.visibility);
|
setTenantVisibility(orgConfig.visibility);
|
||||||
|
setWorksmobileExcluded(orgConfig.worksmobileExcluded);
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ export function TenantProfilePage() {
|
|||||||
orgConfigCandidate,
|
orgConfigCandidate,
|
||||||
])
|
])
|
||||||
: false;
|
: false;
|
||||||
|
const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (overrideForceDomains?: string[]) => {
|
mutationFn: (overrideForceDomains?: string[]) => {
|
||||||
@@ -109,6 +112,7 @@ export function TenantProfilePage() {
|
|||||||
? mergeTenantOrgConfig(baseConfig, {
|
? mergeTenantOrgConfig(baseConfig, {
|
||||||
orgUnitType,
|
orgUnitType,
|
||||||
visibility: tenantVisibility,
|
visibility: tenantVisibility,
|
||||||
|
worksmobileExcluded,
|
||||||
})
|
})
|
||||||
: removeTenantOrgConfig(baseConfig);
|
: removeTenantOrgConfig(baseConfig);
|
||||||
|
|
||||||
@@ -226,78 +230,46 @@ export function TenantProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="bg-[var(--color-panel)] mt-6">
|
<Card className="mt-4 bg-[var(--color-panel)]">
|
||||||
<CardHeader>
|
<CardHeader className="px-5 py-3">
|
||||||
<CardTitle>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
|
<div>
|
||||||
</CardTitle>
|
<CardTitle className="text-lg">
|
||||||
<CardDescription>
|
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
|
||||||
{t(
|
</CardTitle>
|
||||||
"ui.admin.tenants.profile.subtitle",
|
<CardDescription>
|
||||||
"슬러그 및 상태 변경은 즉시 적용됩니다.",
|
{t(
|
||||||
)}
|
"ui.admin.tenants.profile.subtitle",
|
||||||
</CardDescription>
|
"슬러그 및 상태 변경은 즉시 적용됩니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3 px-5 pb-4">
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<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">
|
||||||
{loadError}
|
{loadError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-semibold">
|
|
||||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-semibold">
|
|
||||||
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="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
|
||||||
data-testid="tenant-parent-org-config-layout"
|
data-testid="tenant-profile-primary-row"
|
||||||
className="grid gap-4 md:grid-cols-4"
|
className="grid gap-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.8fr)_minmax(320px,1.4fr)]"
|
||||||
>
|
>
|
||||||
<div
|
<div data-testid="tenant-name-slot" className="space-y-1">
|
||||||
data-testid="tenant-parent-picker-slot"
|
<Label className="text-sm font-semibold">
|
||||||
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
|
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||||
>
|
<span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div data-testid="tenant-slug-slot" className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||||
|
</Label>
|
||||||
|
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
||||||
<ParentTenantSelector
|
<ParentTenantSelector
|
||||||
id="parentId"
|
id="parentId"
|
||||||
label={t(
|
label={t(
|
||||||
@@ -308,18 +280,61 @@ export function TenantProfilePage() {
|
|||||||
onChange={setParentId}
|
onChange={setParentId}
|
||||||
tenants={parentQuery.data?.items ?? []}
|
tenants={parentQuery.data?.items ?? []}
|
||||||
noneLabel={t("ui.common.none", "없음 (최상위)")}
|
noneLabel={t("ui.common.none", "없음 (최상위)")}
|
||||||
helpText={t(
|
|
||||||
"ui.admin.tenants.profile.form.parent_help",
|
|
||||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
|
||||||
)}
|
|
||||||
excludeTenantId={tenantId}
|
excludeTenantId={tenantId}
|
||||||
|
compact
|
||||||
|
controlTestId="tenant-parent-picker-control"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="tenant-profile-config-row"
|
||||||
|
className="grid gap-3 lg:grid-cols-[minmax(190px,1fr)_minmax(150px,0.8fr)_minmax(150px,0.8fr)_minmax(190px,0.9fr)]"
|
||||||
|
>
|
||||||
|
<div data-testid="tenant-type-slot" className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
data-testid="tenant-type-select"
|
||||||
|
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>
|
||||||
{canEditOrgConfig && (
|
{canEditOrgConfig && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
data-testid="tenant-org-unit-type-slot"
|
data-testid="tenant-org-unit-type-slot"
|
||||||
className="space-y-2"
|
className="space-y-1"
|
||||||
>
|
>
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t(
|
{t(
|
||||||
@@ -328,19 +343,20 @@ export function TenantProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
|
data-testid="tenant-org-unit-type-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"
|
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}
|
value={orgUnitType}
|
||||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.common.none", "없음")}</option>
|
<option value="">{t("ui.common.none", "없음")}</option>
|
||||||
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
|
{orgUnitTypeOptions.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="tenant-visibility-slot" className="space-y-2">
|
<div data-testid="tenant-visibility-slot" className="space-y-1">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -360,68 +376,92 @@ export function TenantProfilePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="tenant-worksmobile-excluded-slot"
|
||||||
|
className="space-y-1"
|
||||||
|
>
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.profile.worksmobile_sync",
|
||||||
|
"WORKS 연동",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="worksmobileExcluded"
|
||||||
|
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={worksmobileExcluded ? "excluded" : "enabled"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWorksmobileExcluded(event.target.value === "excluded")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="enabled">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.profile.worksmobile_enabled",
|
||||||
|
"연동",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
<option value="excluded">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.profile.worksmobile_excluded",
|
||||||
|
"제외",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 lg:grid-cols-[minmax(260px,0.9fr)_minmax(360px,1.4fr)_auto]">
|
||||||
<Label className="text-sm font-semibold">
|
<div className="space-y-1">
|
||||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
<Label className="text-sm font-semibold">
|
||||||
</Label>
|
{t("ui.admin.tenants.profile.description", "설명")}
|
||||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
</Label>
|
||||||
</div>
|
<Textarea
|
||||||
<div className="space-y-2">
|
rows={2}
|
||||||
<Label className="text-sm font-semibold">
|
value={description}
|
||||||
{t("ui.admin.tenants.profile.description", "설명")}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
</Label>
|
/>
|
||||||
<Textarea
|
</div>
|
||||||
rows={3}
|
<div className="space-y-1">
|
||||||
value={description}
|
<Label className="text-sm font-semibold">
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
{t(
|
||||||
/>
|
"ui.admin.tenants.profile.allowed_domains",
|
||||||
</div>
|
"허용된 도메인 (콤마로 구분)",
|
||||||
<div className="space-y-2">
|
)}
|
||||||
<Label className="text-sm font-semibold">
|
</Label>
|
||||||
{t(
|
<DomainTagInput
|
||||||
"ui.admin.tenants.profile.allowed_domains",
|
id="tenant-domains"
|
||||||
"허용된 도메인 (콤마로 구분)",
|
value={domains}
|
||||||
)}
|
onChange={setDomains}
|
||||||
</Label>
|
tenants={parentQuery.data?.items ?? []}
|
||||||
<DomainTagInput
|
currentTenantId={tenantId}
|
||||||
id="tenant-domains"
|
confirmedConflicts={forceDomainConflicts}
|
||||||
value={domains}
|
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||||
onChange={setDomains}
|
placeholder="example.com, example.kr"
|
||||||
tenants={parentQuery.data?.items ?? []}
|
/>
|
||||||
currentTenantId={tenantId}
|
</div>
|
||||||
confirmedConflicts={forceDomainConflicts}
|
<div className="space-y-1">
|
||||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
<Label className="text-sm font-semibold">
|
||||||
placeholder="example.com, example.kr"
|
{t("ui.admin.tenants.profile.status", "상태")}
|
||||||
/>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex gap-2">
|
||||||
{t(
|
<Button
|
||||||
"ui.admin.tenants.profile.allowed_domains_help",
|
type="button"
|
||||||
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
|
size="sm"
|
||||||
)}
|
variant={status === "active" ? "default" : "outline"}
|
||||||
</p>
|
onClick={() => setStatus("active")}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-2">
|
{t("ui.common.status.active", "활성")}
|
||||||
<Label className="text-sm font-semibold">
|
</Button>
|
||||||
{t("ui.admin.tenants.profile.status", "상태")}
|
<Button
|
||||||
</Label>
|
type="button"
|
||||||
<div className="flex gap-3">
|
size="sm"
|
||||||
<Button
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
type="button"
|
onClick={() => setStatus("inactive")}
|
||||||
variant={status === "active" ? "default" : "outline"}
|
>
|
||||||
onClick={() => setStatus("active")}
|
{t("ui.common.status.inactive", "비활성")}
|
||||||
>
|
</Button>
|
||||||
{t("ui.common.status.active", "활성")}
|
</div>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={status === "inactive" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatus("inactive")}
|
|
||||||
>
|
|
||||||
{t("ui.common.status.inactive", "비활성")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
@@ -432,7 +472,7 @@ export function TenantProfilePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
canAccessWorksmobile,
|
||||||
|
HANMAC_FAMILY_TENANT_ID,
|
||||||
|
} from "./worksmobileAccess";
|
||||||
|
|
||||||
|
describe("worksmobile access", () => {
|
||||||
|
it("allows super admins", () => {
|
||||||
|
expect(canAccessWorksmobile({ role: "super_admin" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows hanmac-family tenant managers", () => {
|
||||||
|
expect(
|
||||||
|
canAccessWorksmobile({
|
||||||
|
role: "tenant_admin",
|
||||||
|
manageableTenants: [{ id: HANMAC_FAMILY_TENANT_ID }],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canAccessWorksmobile({
|
||||||
|
role: "tenant_admin",
|
||||||
|
manageableTenants: [{ slug: "hanmac-family" }],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects admins that do not manage hanmac-family", () => {
|
||||||
|
expect(
|
||||||
|
canAccessWorksmobile({
|
||||||
|
role: "tenant_admin",
|
||||||
|
manageableTenants: [{ slug: "other-company" }],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canAccessWorksmobile({
|
||||||
|
role: "user",
|
||||||
|
tenantId: HANMAC_FAMILY_TENANT_ID,
|
||||||
|
tenantSlug: "hanmac-family",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(canAccessWorksmobile({ role: "user" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects admins that only manage Worksmobile-excluded hanmac-family tenants", () => {
|
||||||
|
expect(
|
||||||
|
canAccessWorksmobile({
|
||||||
|
role: "tenant_admin",
|
||||||
|
manageableTenants: [
|
||||||
|
{
|
||||||
|
slug: "hanmac-family",
|
||||||
|
config: { worksmobileExcluded: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
adminfront/src/features/tenants/routes/worksmobileAccess.ts
Normal file
60
adminfront/src/features/tenants/routes/worksmobileAccess.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { isSuperAdminRole } from "../../../lib/roles";
|
||||||
|
|
||||||
|
export const HANMAC_FAMILY_TENANT_ID = "038326b6-954a-48a7-a85f-efd83f62b82a";
|
||||||
|
export const HANMAC_FAMILY_TENANT_SLUG = "hanmac-family";
|
||||||
|
|
||||||
|
export type WorksmobileAccessProfile = {
|
||||||
|
role?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
tenant?: {
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
manageableTenants?: Array<{
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isWorksmobileExcludedConfig(
|
||||||
|
config?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const rawValue = config?.worksmobileExcluded;
|
||||||
|
return (
|
||||||
|
rawValue === true || String(rawValue ?? "").trim().toLowerCase() === "true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProfileTenantWorksmobileExcluded(
|
||||||
|
profile?: WorksmobileAccessProfile | null,
|
||||||
|
) {
|
||||||
|
if (isWorksmobileExcludedConfig(profile?.tenant?.config)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (profile?.manageableTenants ?? []).some((tenant) => {
|
||||||
|
const isCurrentTenant =
|
||||||
|
(profile?.tenantId && tenant.id === profile.tenantId) ||
|
||||||
|
(profile?.tenantSlug && tenant.slug === profile.tenantSlug);
|
||||||
|
return isCurrentTenant && isWorksmobileExcludedConfig(tenant.config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessWorksmobile(
|
||||||
|
profile?: WorksmobileAccessProfile | null,
|
||||||
|
) {
|
||||||
|
if (isSuperAdminRole(profile?.role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isProfileTenantWorksmobileExcluded(profile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (profile?.manageableTenants ?? []).some(
|
||||||
|
(tenant) =>
|
||||||
|
!isWorksmobileExcludedConfig(tenant.config) &&
|
||||||
|
(tenant.id === HANMAC_FAMILY_TENANT_ID ||
|
||||||
|
tenant.slug === HANMAC_FAMILY_TENANT_SLUG),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
mergeTenantOrgConfig,
|
mergeTenantOrgConfig,
|
||||||
ORG_UNIT_TYPE_OPTIONS,
|
ORG_UNIT_TYPE_OPTIONS,
|
||||||
readTenantOrgConfig,
|
readTenantOrgConfig,
|
||||||
|
removeTenantOrgConfig,
|
||||||
shouldAllowHanmacOrgConfig,
|
shouldAllowHanmacOrgConfig,
|
||||||
} from "./orgConfig";
|
} from "./orgConfig";
|
||||||
|
|
||||||
@@ -49,17 +50,69 @@ describe("tenant org config", () => {
|
|||||||
it("reads and writes tenant visibility and org unit type", () => {
|
it("reads and writes tenant visibility and org unit type", () => {
|
||||||
expect(
|
expect(
|
||||||
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
|
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
|
||||||
).toEqual({ orgUnitType: "팀", visibility: "private" });
|
).toEqual({
|
||||||
|
orgUnitType: "팀",
|
||||||
|
visibility: "private",
|
||||||
|
worksmobileExcluded: false,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
|
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
|
||||||
).toEqual({ orgUnitType: "센터", visibility: "internal" });
|
).toEqual({
|
||||||
|
orgUnitType: "센터",
|
||||||
|
visibility: "internal",
|
||||||
|
worksmobileExcluded: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
mergeTenantOrgConfig(
|
mergeTenantOrgConfig(
|
||||||
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
|
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
|
||||||
{ orgUnitType: "", visibility: "internal" },
|
{
|
||||||
|
orgUnitType: "",
|
||||||
|
visibility: "internal",
|
||||||
|
worksmobileExcluded: false,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
).toEqual({ userSchema: [], visibility: "internal" });
|
).toEqual({
|
||||||
|
userSchema: [],
|
||||||
|
visibility: "internal",
|
||||||
|
worksmobileExcluded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads, writes, and removes the Worksmobile exclusion flag", () => {
|
||||||
|
expect(
|
||||||
|
readTenantOrgConfig({ worksmobileExcluded: true }),
|
||||||
|
).toMatchObject({
|
||||||
|
worksmobileExcluded: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
readTenantOrgConfig({ worksmobileExcluded: "true" }),
|
||||||
|
).toMatchObject({
|
||||||
|
worksmobileExcluded: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mergeTenantOrgConfig(
|
||||||
|
{ userSchema: [], worksmobileExcluded: false },
|
||||||
|
{
|
||||||
|
orgUnitType: "팀",
|
||||||
|
visibility: "private",
|
||||||
|
worksmobileExcluded: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
userSchema: [],
|
||||||
|
orgUnitType: "팀",
|
||||||
|
visibility: "private",
|
||||||
|
worksmobileExcluded: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
removeTenantOrgConfig({
|
||||||
|
userSchema: [],
|
||||||
|
orgUnitType: "팀",
|
||||||
|
visibility: "private",
|
||||||
|
worksmobileExcluded: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ userSchema: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes task-force and executive-direct org unit types", () => {
|
it("includes task-force and executive-direct org unit types", () => {
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export const ORG_UNIT_TYPE_OPTIONS = [
|
|||||||
"임원직속",
|
"임원직속",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const USER_GROUP_ORG_UNIT_TYPE_OPTIONS = [
|
||||||
|
"팀",
|
||||||
|
"TF",
|
||||||
|
"TF팀",
|
||||||
|
"셀",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const TENANT_VISIBILITY_OPTIONS = [
|
export const TENANT_VISIBILITY_OPTIONS = [
|
||||||
{ label: "공개", value: "public" },
|
{ label: "공개", value: "public" },
|
||||||
{ label: "내부", value: "internal" },
|
{ label: "내부", value: "internal" },
|
||||||
@@ -26,6 +33,7 @@ export type TenantVisibility =
|
|||||||
export type TenantOrgConfig = {
|
export type TenantOrgConfig = {
|
||||||
orgUnitType: string;
|
orgUnitType: string;
|
||||||
visibility: TenantVisibility;
|
visibility: TenantVisibility;
|
||||||
|
worksmobileExcluded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
|
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
|
||||||
@@ -55,17 +63,29 @@ export function shouldAllowHanmacOrgConfig(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOrgUnitTypeOptionsForTenantType(type: string) {
|
||||||
|
return type === "USER_GROUP"
|
||||||
|
? USER_GROUP_ORG_UNIT_TYPE_OPTIONS
|
||||||
|
: ORG_UNIT_TYPE_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
export function readTenantOrgConfig(
|
export function readTenantOrgConfig(
|
||||||
config: Record<string, unknown> | undefined,
|
config: Record<string, unknown> | undefined,
|
||||||
): TenantOrgConfig {
|
): TenantOrgConfig {
|
||||||
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
|
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
|
||||||
const rawOrgUnitType = String(config?.orgUnitType ?? "");
|
const rawOrgUnitType = String(config?.orgUnitType ?? "");
|
||||||
|
const rawWorksmobileExcluded = config?.worksmobileExcluded;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
|
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
|
||||||
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
|
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
|
||||||
? (rawVisibility as TenantVisibility)
|
? (rawVisibility as TenantVisibility)
|
||||||
: "public",
|
: "public",
|
||||||
|
worksmobileExcluded:
|
||||||
|
rawWorksmobileExcluded === true ||
|
||||||
|
String(rawWorksmobileExcluded ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +96,7 @@ export function mergeTenantOrgConfig(
|
|||||||
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
|
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
|
||||||
const merged = { ...rest };
|
const merged = { ...rest };
|
||||||
merged.visibility = next.visibility;
|
merged.visibility = next.visibility;
|
||||||
|
merged.worksmobileExcluded = next.worksmobileExcluded;
|
||||||
|
|
||||||
if (next.orgUnitType) {
|
if (next.orgUnitType) {
|
||||||
merged.orgUnitType = next.orgUnitType;
|
merged.orgUnitType = next.orgUnitType;
|
||||||
@@ -90,6 +111,7 @@ export function removeTenantOrgConfig(
|
|||||||
const {
|
const {
|
||||||
orgUnitType: _orgUnitType,
|
orgUnitType: _orgUnitType,
|
||||||
visibility: _visibility,
|
visibility: _visibility,
|
||||||
|
worksmobileExcluded: _worksmobileExcluded,
|
||||||
...rest
|
...rest
|
||||||
} = config ?? {};
|
} = config ?? {};
|
||||||
return rest;
|
return rest;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("tenantCsvImport", () => {
|
|||||||
|
|
||||||
it("parses tenant CSV rows with the supported import columns", () => {
|
it("parses tenant CSV rows with the supported import columns", () => {
|
||||||
const rows = parseTenantCSV(
|
const rows = parseTenantCSV(
|
||||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터,no\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(rows).toEqual([
|
expect(rows).toEqual([
|
||||||
@@ -82,6 +82,7 @@ describe("tenantCsvImport", () => {
|
|||||||
emailDomain: "hanmac-tech.example.com",
|
emailDomain: "hanmac-tech.example.com",
|
||||||
visibility: "internal",
|
visibility: "internal",
|
||||||
orgUnitType: "센터",
|
orgUnitType: "센터",
|
||||||
|
worksmobileSync: "no",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -111,7 +112,7 @@ describe("tenantCsvImport", () => {
|
|||||||
|
|
||||||
it("serializes selected matches by filling tenant_id before upload", () => {
|
it("serializes selected matches by filling tenant_id before upload", () => {
|
||||||
const rows = parseTenantCSV(
|
const rows = parseTenantCSV(
|
||||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
|
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no\n",
|
||||||
);
|
);
|
||||||
const preview = buildTenantImportPreview(rows, tenants);
|
const preview = buildTenantImportPreview(rows, tenants);
|
||||||
const csv = serializeTenantImportCSV(preview, {
|
const csv = serializeTenantImportCSV(preview, {
|
||||||
@@ -119,10 +120,10 @@ describe("tenantCsvImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(csv.split("\n")[0]).toBe(
|
expect(csv.split("\n")[0]).toBe(
|
||||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
|
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
|
||||||
);
|
);
|
||||||
expect(csv).toContain(
|
expect(csv).toContain(
|
||||||
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀",
|
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,10 +254,10 @@ ${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(csv.split("\n")[0]).toBe(
|
expect(csv.split("\n")[0]).toBe(
|
||||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
|
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
|
||||||
);
|
);
|
||||||
expect(csv).toContain(
|
expect(csv).toContain(
|
||||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,,,,yes",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type TenantCSVRow = {
|
|||||||
emailDomain: string;
|
emailDomain: string;
|
||||||
visibility: string;
|
visibility: string;
|
||||||
orgUnitType: string;
|
orgUnitType: string;
|
||||||
|
worksmobileSync: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantCSVParseOptions = {
|
export type TenantCSVParseOptions = {
|
||||||
@@ -80,6 +81,7 @@ const importHeaders = [
|
|||||||
"email_domain",
|
"email_domain",
|
||||||
"visibility",
|
"visibility",
|
||||||
"org_unit_type",
|
"org_unit_type",
|
||||||
|
"worksmobile_sync",
|
||||||
];
|
];
|
||||||
|
|
||||||
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||||
@@ -116,6 +118,11 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
|
|||||||
organization_type: "orgUnitType",
|
organization_type: "orgUnitType",
|
||||||
orgtype: "orgUnitType",
|
orgtype: "orgUnitType",
|
||||||
org_type: "orgUnitType",
|
org_type: "orgUnitType",
|
||||||
|
worksmobile: "worksmobileSync",
|
||||||
|
worksmobilesync: "worksmobileSync",
|
||||||
|
worksmobile_sync: "worksmobileSync",
|
||||||
|
works_sync: "worksmobileSync",
|
||||||
|
works: "worksmobileSync",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseTenantCSV(
|
export function parseTenantCSV(
|
||||||
@@ -175,6 +182,7 @@ export function parseTenantCSV(
|
|||||||
emailDomain: value("emailDomain"),
|
emailDomain: value("emailDomain"),
|
||||||
visibility: value("visibility"),
|
visibility: value("visibility"),
|
||||||
orgUnitType: value("orgUnitType"),
|
orgUnitType: value("orgUnitType"),
|
||||||
|
worksmobileSync: normalizeWorksmobileSync(value("worksmobileSync")),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -305,6 +313,7 @@ export function serializeTenantImportCSV(
|
|||||||
preview.row.emailDomain,
|
preview.row.emailDomain,
|
||||||
preview.row.visibility,
|
preview.row.visibility,
|
||||||
preview.row.orgUnitType,
|
preview.row.orgUnitType,
|
||||||
|
preview.row.worksmobileSync || "yes",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
||||||
@@ -528,6 +537,30 @@ function normalizeHeader(value: string) {
|
|||||||
return value.trim().toLowerCase().replaceAll(" ", "_");
|
return value.trim().toLowerCase().replaceAll(" ", "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWorksmobileSync(value: string) {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"no",
|
||||||
|
"n",
|
||||||
|
"false",
|
||||||
|
"0",
|
||||||
|
"off",
|
||||||
|
"none",
|
||||||
|
"excluded",
|
||||||
|
"exclude",
|
||||||
|
"not_sync",
|
||||||
|
"not-synced",
|
||||||
|
"미연동",
|
||||||
|
"연동안함",
|
||||||
|
"제외",
|
||||||
|
].includes(normalized)
|
||||||
|
) {
|
||||||
|
return "no";
|
||||||
|
}
|
||||||
|
return "yes";
|
||||||
|
}
|
||||||
|
|
||||||
function slugFromMailingList(value: string) {
|
function slugFromMailingList(value: string) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
return normalizeTenantSlug(value.split("@")[0] ?? value);
|
return normalizeTenantSlug(value.split("@")[0] ?? value);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { isSuperAdminRole } from "../../lib/roles";
|
|||||||
import {
|
import {
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
|
getTenantGradeOptions,
|
||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
@@ -896,63 +897,73 @@ function UserCreatePage() {
|
|||||||
data-testid={`appointment-tenant-owner-line-${index}`}
|
data-testid={`appointment-tenant-owner-line-${index}`}
|
||||||
>
|
>
|
||||||
<Label>소속 테넌트</Label>
|
<Label>소속 테넌트</Label>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div
|
||||||
<Button
|
className="flex items-center justify-between gap-3"
|
||||||
type="button"
|
data-testid={`appointment-tenant-owner-controls-${index}`}
|
||||||
variant="outline"
|
>
|
||||||
onClick={() =>
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
setPickerTarget({
|
<Button
|
||||||
kind: "appointment",
|
type="button"
|
||||||
index,
|
variant="outline"
|
||||||
})
|
className="min-w-0 max-w-full"
|
||||||
}
|
onClick={() =>
|
||||||
disabled={isResolvingTenant}
|
setPickerTarget({
|
||||||
data-testid={`appointment-tenant-picker-${index}`}
|
kind: "appointment",
|
||||||
>
|
index,
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
|
||||||
{appointment.tenantName || "테넌트 선택"}
|
|
||||||
</Button>
|
|
||||||
{appointment.tenantSlug && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{appointment.tenantSlug}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<label className="flex items-center gap-3 text-sm">
|
|
||||||
<Switch
|
|
||||||
checked={appointment.isPrimary === true}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateAppointment(index, {
|
|
||||||
isPrimary: checked === true,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label={t(
|
disabled={isResolvingTenant}
|
||||||
|
data-testid={`appointment-tenant-picker-${index}`}
|
||||||
|
>
|
||||||
|
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{appointment.tenantName || "테넌트 선택"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{appointment.tenantSlug && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{appointment.tenantSlug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Switch
|
||||||
|
checked={appointment.isPrimary === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateAppointment(index, {
|
||||||
|
isPrimary: checked === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
|
"대표 조직",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
"ui.admin.users.detail.form.appointment_owner",
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
"대표 조직",
|
"대표 조직",
|
||||||
)}
|
)}
|
||||||
/>
|
</label>
|
||||||
{t(
|
<label className="flex items-center gap-2 text-sm">
|
||||||
"ui.admin.users.detail.form.appointment_owner",
|
<Switch
|
||||||
"대표 조직",
|
checked={appointment.isManager === true}
|
||||||
)}
|
onCheckedChange={(checked) =>
|
||||||
</label>
|
updateAppointment(index, {
|
||||||
<label className="flex items-center gap-3 text-sm">
|
isManager: checked === true,
|
||||||
<Switch
|
})
|
||||||
checked={appointment.isManager === true}
|
}
|
||||||
onCheckedChange={(checked) =>
|
aria-label={t(
|
||||||
updateAppointment(index, {
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
isManager: checked === true,
|
"조직장",
|
||||||
})
|
)}
|
||||||
}
|
/>
|
||||||
aria-label={t(
|
{t(
|
||||||
"ui.admin.users.detail.form.appointment_manager",
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
"조직장",
|
"조직장",
|
||||||
)}
|
)}
|
||||||
/>
|
</label>
|
||||||
{t(
|
</div>
|
||||||
"ui.admin.users.detail.form.appointment_manager",
|
|
||||||
"조직장",
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -964,15 +975,26 @@ function UserCreatePage() {
|
|||||||
<Label htmlFor={`appointment-grade-${index}`}>
|
<Label htmlFor={`appointment-grade-${index}`}>
|
||||||
직급
|
직급
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<select
|
||||||
id={`appointment-grade-${index}`}
|
id={`appointment-grade-${index}`}
|
||||||
|
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={appointment.grade ?? ""}
|
value={appointment.grade ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAppointment(index, {
|
updateAppointment(index, {
|
||||||
grade: event.target.value,
|
grade: event.target.value || "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<option value="">없음</option>
|
||||||
|
{getTenantGradeOptions(
|
||||||
|
appointment,
|
||||||
|
tenants,
|
||||||
|
).map((grade) => (
|
||||||
|
<option key={grade} value={grade}>
|
||||||
|
{grade}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`appointment-job-title-${index}`}>
|
<Label htmlFor={`appointment-job-title-${index}`}>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import { generateSecurePassword } from "../../lib/utils";
|
|||||||
import {
|
import {
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
|
getTenantGradeOptions,
|
||||||
isHanmacFamilyTenant,
|
isHanmacFamilyTenant,
|
||||||
isHanmacFamilyUser,
|
isHanmacFamilyUser,
|
||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
@@ -1445,67 +1446,78 @@ function UserDetailPage() {
|
|||||||
"소속 테넌트",
|
"소속 테넌트",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div
|
||||||
<Button
|
className="flex items-center justify-between gap-3"
|
||||||
type="button"
|
data-testid={`detail-appointment-tenant-owner-controls-${index}`}
|
||||||
variant="outline"
|
>
|
||||||
onClick={() =>
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
setPickerTarget({
|
<Button
|
||||||
kind: "appointment",
|
type="button"
|
||||||
index,
|
variant="outline"
|
||||||
})
|
className="min-w-0 max-w-full"
|
||||||
}
|
onClick={() =>
|
||||||
disabled={isResolvingTenant}
|
setPickerTarget({
|
||||||
>
|
kind: "appointment",
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
index,
|
||||||
{appointment.tenantName ||
|
|
||||||
t(
|
|
||||||
"ui.admin.users.detail.form.pick_tenant",
|
|
||||||
"테넌트 선택",
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{appointment.tenantSlug && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{appointment.tenantSlug}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<label className="flex items-center gap-3 text-sm">
|
|
||||||
<Switch
|
|
||||||
checked={appointment.isPrimary === true}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateAppointment(index, {
|
|
||||||
isPrimary: checked === true,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={appointment.isPrimary === true}
|
disabled={isResolvingTenant}
|
||||||
aria-label={t(
|
data-testid={`detail-appointment-tenant-picker-${index}`}
|
||||||
|
>
|
||||||
|
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{appointment.tenantName ||
|
||||||
|
t(
|
||||||
|
"ui.admin.users.detail.form.pick_tenant",
|
||||||
|
"테넌트 선택",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{appointment.tenantSlug && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{appointment.tenantSlug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Switch
|
||||||
|
checked={appointment.isPrimary === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateAppointment(index, {
|
||||||
|
isPrimary: checked === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={appointment.isPrimary === true}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
|
"대표 조직",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
"ui.admin.users.detail.form.appointment_owner",
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
"대표 조직",
|
"대표 조직",
|
||||||
)}
|
)}
|
||||||
/>
|
</label>
|
||||||
{t(
|
<label className="flex items-center gap-2 text-sm">
|
||||||
"ui.admin.users.detail.form.appointment_owner",
|
<Switch
|
||||||
"대표 조직",
|
checked={appointment.isManager === true}
|
||||||
)}
|
onCheckedChange={(checked) =>
|
||||||
</label>
|
updateAppointment(index, {
|
||||||
<label className="flex items-center gap-3 text-sm">
|
isManager: checked === true,
|
||||||
<Switch
|
})
|
||||||
checked={appointment.isManager === true}
|
}
|
||||||
onCheckedChange={(checked) =>
|
aria-label={t(
|
||||||
updateAppointment(index, {
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
isManager: checked === true,
|
"조직장",
|
||||||
})
|
)}
|
||||||
}
|
/>
|
||||||
aria-label={t(
|
{t(
|
||||||
"ui.admin.users.detail.form.appointment_manager",
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
"조직장",
|
"조직장",
|
||||||
)}
|
)}
|
||||||
/>
|
</label>
|
||||||
{t(
|
</div>
|
||||||
"ui.admin.users.detail.form.appointment_manager",
|
|
||||||
"조직장",
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1522,15 +1534,26 @@ function UserDetailPage() {
|
|||||||
"직급",
|
"직급",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<select
|
||||||
id={`detail-appointment-grade-${index}`}
|
id={`detail-appointment-grade-${index}`}
|
||||||
|
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={appointment.grade ?? ""}
|
value={appointment.grade ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAppointment(index, {
|
updateAppointment(index, {
|
||||||
grade: event.target.value,
|
grade: event.target.value || "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<option value="">없음</option>
|
||||||
|
{getTenantGradeOptions(
|
||||||
|
appointment,
|
||||||
|
tenants,
|
||||||
|
).map((grade) => (
|
||||||
|
<option key={grade} value={grade}>
|
||||||
|
{grade}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function buildUserTenantPreviewRows(
|
|||||||
emailDomain: user.tenantImport?.emailDomain ?? "",
|
emailDomain: user.tenantImport?.emailDomain ?? "",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
orgUnitType: "node",
|
orgUnitType: "node",
|
||||||
|
worksmobileSync: "yes",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildAuthenticatedOrgChartUrl,
|
buildAuthenticatedOrgChartUrl,
|
||||||
buildOrgChartTenantPickerUrl,
|
buildOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
|
getTenantGradeOptions,
|
||||||
isHanmacFamilyUser,
|
isHanmacFamilyUser,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
@@ -114,6 +115,22 @@ describe("orgChartPicker", () => {
|
|||||||
type: "COMPANY",
|
type: "COMPANY",
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "internal-id",
|
||||||
|
slug: "internal",
|
||||||
|
name: "Internal",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: undefined,
|
||||||
|
config: { visibility: "internal" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "private-id",
|
||||||
|
slug: "private",
|
||||||
|
name: "Private",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: undefined,
|
||||||
|
visibility: "private",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "hanmac-family-id",
|
id: "hanmac-family-id",
|
||||||
slug: "hanmac-family",
|
slug: "hanmac-family",
|
||||||
@@ -249,4 +266,54 @@ describe("orgChartPicker", () => {
|
|||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns GPDTDC rank options for GPDTDC subtree and general Hanmac ranks otherwise", () => {
|
||||||
|
const tenants = [
|
||||||
|
{
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
name: "한맥가족",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpdtdc-id",
|
||||||
|
slug: "gpdtdc",
|
||||||
|
name: "총괄기획&기술개발센터",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: "hanmac-family-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpdtdc-team-id",
|
||||||
|
slug: "gpdtdc-team",
|
||||||
|
name: "연구팀",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
parentId: "gpdtdc-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hanmac-id",
|
||||||
|
slug: "hanmac",
|
||||||
|
name: "한맥기술",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: "hanmac-family-id",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTenantGradeOptions({ tenantId: "gpdtdc-team-id" }, tenants),
|
||||||
|
).toEqual(["연구원", "선임", "책임", "수석", "부사장", "사장"]);
|
||||||
|
expect(getTenantGradeOptions({ tenantSlug: "hanmac" }, tenants)).toEqual([
|
||||||
|
"사원",
|
||||||
|
"대리",
|
||||||
|
"과장",
|
||||||
|
"차장",
|
||||||
|
"부장",
|
||||||
|
"이사",
|
||||||
|
"상무",
|
||||||
|
"전무",
|
||||||
|
"부사장",
|
||||||
|
"사장",
|
||||||
|
"회장",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type TenantFilterTarget = {
|
|||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
tenantName?: string;
|
tenantName?: string;
|
||||||
|
visibility?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HanmacFamilyUserTarget = {
|
export type HanmacFamilyUserTarget = {
|
||||||
@@ -43,6 +45,29 @@ type OrgChartLoginOptions = {
|
|||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GPDTDC_GRADE_OPTIONS = [
|
||||||
|
"연구원",
|
||||||
|
"선임",
|
||||||
|
"책임",
|
||||||
|
"수석",
|
||||||
|
"부사장",
|
||||||
|
"사장",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const HANMAC_FAMILY_GRADE_OPTIONS = [
|
||||||
|
"사원",
|
||||||
|
"대리",
|
||||||
|
"과장",
|
||||||
|
"차장",
|
||||||
|
"부장",
|
||||||
|
"이사",
|
||||||
|
"상무",
|
||||||
|
"전무",
|
||||||
|
"부사장",
|
||||||
|
"사장",
|
||||||
|
"회장",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function isSystemTenant(tenant: TenantFilterTarget) {
|
function isSystemTenant(tenant: TenantFilterTarget) {
|
||||||
const slug = tenant.slug?.trim().toLowerCase();
|
const slug = tenant.slug?.trim().toLowerCase();
|
||||||
const type = tenant.type?.trim().toUpperCase();
|
const type = tenant.type?.trim().toUpperCase();
|
||||||
@@ -56,6 +81,73 @@ function isSystemTenant(tenant: TenantFilterTarget) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTenantTarget<T extends TenantFilterTarget>(
|
||||||
|
target: TenantFilterTarget | undefined,
|
||||||
|
tenants: T[],
|
||||||
|
) {
|
||||||
|
if (!target) return undefined;
|
||||||
|
const tenantID = target.id ?? target.tenantId ?? "";
|
||||||
|
const tenantSlug = target.slug ?? target.tenantSlug ?? "";
|
||||||
|
return (
|
||||||
|
tenants.find((tenant) => tenantID && tenant.id === tenantID) ??
|
||||||
|
tenants.find(
|
||||||
|
(tenant) =>
|
||||||
|
tenantSlug &&
|
||||||
|
tenant.slug?.trim().toLowerCase() === tenantSlug.trim().toLowerCase(),
|
||||||
|
) ??
|
||||||
|
target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGPDTDCTenant<T extends TenantFilterTarget>(
|
||||||
|
target: TenantFilterTarget | undefined,
|
||||||
|
tenants: T[],
|
||||||
|
) {
|
||||||
|
const tenant = resolveTenantTarget(target, tenants);
|
||||||
|
if (!tenant) return false;
|
||||||
|
|
||||||
|
const tenantById = new Map(
|
||||||
|
tenants
|
||||||
|
.filter((item) => item.id?.trim())
|
||||||
|
.map((item) => [item.id as string, item]),
|
||||||
|
);
|
||||||
|
let current: TenantFilterTarget | undefined = tenant;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
const slug = current.slug?.trim().toLowerCase();
|
||||||
|
if (slug === "gpdtdc") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const parentId = current.parentId ?? "";
|
||||||
|
if (!parentId || visited.has(parentId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visited.add(parentId);
|
||||||
|
current = tenantById.get(parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenantGradeOptions<T extends TenantFilterTarget>(
|
||||||
|
target: TenantFilterTarget | undefined,
|
||||||
|
tenants: T[],
|
||||||
|
) {
|
||||||
|
return isGPDTDCTenant(target, tenants)
|
||||||
|
? [...GPDTDC_GRADE_OPTIONS]
|
||||||
|
: [...HANMAC_FAMILY_GRADE_OPTIONS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicRepresentativeTenant(tenant: TenantFilterTarget) {
|
||||||
|
const visibility = String(
|
||||||
|
tenant.visibility ?? tenant.config?.visibility ?? "public",
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return visibility !== "internal" && visibility !== "private";
|
||||||
|
}
|
||||||
|
|
||||||
function isInTenantSubtree<T extends TenantFilterTarget>(
|
function isInTenantSubtree<T extends TenantFilterTarget>(
|
||||||
tenant: T,
|
tenant: T,
|
||||||
rootTenantId: string,
|
rootTenantId: string,
|
||||||
@@ -187,6 +279,7 @@ export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
|
|||||||
return tenants.filter(
|
return tenants.filter(
|
||||||
(tenant) =>
|
(tenant) =>
|
||||||
!isSystemTenant(tenant) &&
|
!isSystemTenant(tenant) &&
|
||||||
|
isPublicRepresentativeTenant(tenant) &&
|
||||||
!isInTenantSubtree(tenant, rootTenantId, tenantById),
|
!isInTenantSubtree(tenant, rootTenantId, tenantById),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -818,6 +818,10 @@ export type WorksmobileCredentialBatchFailure = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorksmobilePendingJobDeleteResult = {
|
||||||
|
deletedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorksmobileComparisonItem = {
|
export type WorksmobileComparisonItem = {
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
baronId?: string;
|
baronId?: string;
|
||||||
@@ -978,6 +982,13 @@ export async function deleteWorksmobileCredentialBatchPasswords(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteWorksmobilePendingJobs(tenantId: string) {
|
||||||
|
const { data } = await apiClient.delete<WorksmobilePendingJobDeleteResult>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/pending`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
||||||
const { data } = await apiClient.post(
|
const { data } = await apiClient.post(
|
||||||
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ test.describe("Tenants Management", () => {
|
|||||||
await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible();
|
await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should place hanmac org config beside parent tenant picker", async ({
|
test("should place tenant profile core settings in dense rows", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
@@ -1119,29 +1119,102 @@ test.describe("Tenants Management", () => {
|
|||||||
|
|
||||||
await page.goto("/tenants/team-1");
|
await page.goto("/tenants/team-1");
|
||||||
|
|
||||||
const layout = page.getByTestId("tenant-parent-org-config-layout");
|
const topLayout = page.getByTestId("tenant-profile-primary-row");
|
||||||
await expect(layout).toBeVisible({ timeout: 20000 });
|
const configLayout = page.getByTestId("tenant-profile-config-row");
|
||||||
await expect(layout).toContainText("상위 테넌트");
|
await expect(topLayout).toBeVisible({ timeout: 20000 });
|
||||||
await expect(layout).toContainText("조직 세부타입");
|
await expect(configLayout).toBeVisible();
|
||||||
await expect(layout).toContainText("공개 범위");
|
await expect(topLayout).toContainText("테넌트 이름");
|
||||||
|
await expect(topLayout).toContainText("슬러그");
|
||||||
|
await expect(topLayout).toContainText("상위 테넌트");
|
||||||
|
await expect(configLayout).toContainText("테넌트 유형");
|
||||||
|
await expect(configLayout).toContainText("조직 세부타입");
|
||||||
|
await expect(configLayout).toContainText("공개 범위");
|
||||||
|
await expect(configLayout).toContainText("WORKS 연동");
|
||||||
|
const orgUnitTypeSelect = page.getByTestId("tenant-org-unit-type-select");
|
||||||
|
await expect(orgUnitTypeSelect).toBeVisible();
|
||||||
|
await expect(orgUnitTypeSelect.locator("option")).toHaveText([
|
||||||
|
"없음",
|
||||||
|
"팀",
|
||||||
|
"TF",
|
||||||
|
"TF팀",
|
||||||
|
"셀",
|
||||||
|
]);
|
||||||
|
|
||||||
const columns = await layout.evaluate(
|
const topColumns = await topLayout.evaluate(
|
||||||
(element) => window.getComputedStyle(element).gridTemplateColumns,
|
(element) => window.getComputedStyle(element).gridTemplateColumns,
|
||||||
);
|
);
|
||||||
expect(columns.split(" ").length).toBe(4);
|
const configColumns = await configLayout.evaluate(
|
||||||
|
(element) => window.getComputedStyle(element).gridTemplateColumns,
|
||||||
|
);
|
||||||
|
expect(topColumns.split(" ").length).toBe(3);
|
||||||
|
expect(configColumns.split(" ").length).toBe(4);
|
||||||
|
|
||||||
const parentWidth = await page
|
const nameTop = await page
|
||||||
|
.getByTestId("tenant-name-slot")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
const slugTop = await page
|
||||||
|
.getByTestId("tenant-slug-slot")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
const parentTop = await page
|
||||||
.getByTestId("tenant-parent-picker-slot")
|
.getByTestId("tenant-parent-picker-slot")
|
||||||
.evaluate((element) => element.getBoundingClientRect().width);
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
const orgUnitWidth = await page
|
const nameInputHeight = await page
|
||||||
|
.getByTestId("tenant-name-slot")
|
||||||
|
.locator("input")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
const slugInputHeight = await page
|
||||||
|
.getByTestId("tenant-slug-slot")
|
||||||
|
.locator("input")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
const parentControlHeight = await page
|
||||||
|
.getByTestId("tenant-parent-picker-control")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
const typeTop = await page
|
||||||
|
.getByTestId("tenant-type-slot")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
const orgUnitTop = await page
|
||||||
.getByTestId("tenant-org-unit-type-slot")
|
.getByTestId("tenant-org-unit-type-slot")
|
||||||
.evaluate((element) => element.getBoundingClientRect().width);
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
const visibilityWidth = await page
|
const visibilityTop = await page
|
||||||
.getByTestId("tenant-visibility-slot")
|
.getByTestId("tenant-visibility-slot")
|
||||||
.evaluate((element) => element.getBoundingClientRect().width);
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
const worksExcludedTop = await page
|
||||||
|
.getByTestId("tenant-worksmobile-excluded-slot")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
|
||||||
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
|
expect(Math.abs(nameTop - slugTop)).toBeLessThan(4);
|
||||||
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
|
expect(Math.abs(nameTop - parentTop)).toBeLessThan(4);
|
||||||
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
|
expect(Math.abs(nameInputHeight - slugInputHeight)).toBeLessThan(2);
|
||||||
|
expect(Math.abs(nameInputHeight - parentControlHeight)).toBeLessThan(4);
|
||||||
|
expect(Math.abs(typeTop - orgUnitTop)).toBeLessThan(4);
|
||||||
|
expect(Math.abs(typeTop - visibilityTop)).toBeLessThan(4);
|
||||||
|
expect(Math.abs(typeTop - worksExcludedTop)).toBeLessThan(4);
|
||||||
|
|
||||||
|
await page.getByTestId("tenant-type-select").selectOption("COMPANY");
|
||||||
|
await expect(orgUnitTypeSelect.locator("option")).toHaveText([
|
||||||
|
"없음",
|
||||||
|
"실",
|
||||||
|
"팀",
|
||||||
|
"TF",
|
||||||
|
"TF팀",
|
||||||
|
"센터",
|
||||||
|
"디비전",
|
||||||
|
"셀",
|
||||||
|
"본부",
|
||||||
|
"지역본부",
|
||||||
|
"부",
|
||||||
|
"임원직속",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
horizontal:
|
||||||
|
document.documentElement.scrollWidth >
|
||||||
|
document.documentElement.clientWidth,
|
||||||
|
vertical:
|
||||||
|
document.documentElement.scrollHeight >
|
||||||
|
document.documentElement.clientHeight,
|
||||||
|
}));
|
||||||
|
expect(overflow.horizontal).toBe(false);
|
||||||
|
expect(overflow.vertical).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -729,7 +729,6 @@ test.describe("User Management", () => {
|
|||||||
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
|
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
|
||||||
await page.getByRole("switch", { name: /대표 조직/i }).click();
|
await page.getByRole("switch", { name: /대표 조직/i }).click();
|
||||||
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
||||||
await page.getByLabel(/^직급$/i).fill("책임");
|
|
||||||
await page.getByLabel(/^직책$/i).fill("팀장");
|
await page.getByLabel(/^직책$/i).fill("팀장");
|
||||||
|
|
||||||
await page.locator('input[name="name"]').fill("Family User");
|
await page.locator('input[name="name"]').fill("Family User");
|
||||||
@@ -829,6 +828,7 @@ test.describe("User Management", () => {
|
|||||||
test("should show Hanmac family appointments layout on user detail", async ({
|
test("should show Hanmac family appointments layout on user detail", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 520, height: 900 });
|
||||||
await page.route(/\/admin\/users\/u-1$/, async (route) => {
|
await page.route(/\/admin\/users\/u-1$/, async (route) => {
|
||||||
if (route.request().method() === "GET") {
|
if (route.request().method() === "GET") {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
@@ -847,7 +847,7 @@ test.describe("User Management", () => {
|
|||||||
{
|
{
|
||||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||||
tenantSlug: "tech-planning",
|
tenantSlug: "tech-planning",
|
||||||
tenantName: "기술기획",
|
tenantName: "기술기획 장기 운영 전략 조직",
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
grade: "책임",
|
grade: "책임",
|
||||||
@@ -872,6 +872,32 @@ test.describe("User Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("detail-appointment-tenant-owner-line-0"),
|
page.getByTestId("detail-appointment-tenant-owner-line-0"),
|
||||||
).toContainText(/기술기획|대표 조직|조직장/);
|
).toContainText(/기술기획|대표 조직|조직장/);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("detail-appointment-tenant-owner-controls-0"),
|
||||||
|
).toHaveCSS("flex-wrap", "nowrap");
|
||||||
|
|
||||||
|
const tenantPickerBox = await page
|
||||||
|
.getByTestId("detail-appointment-tenant-picker-0")
|
||||||
|
.boundingBox();
|
||||||
|
const ownerSwitchBox = await page
|
||||||
|
.getByTestId("detail-appointment-row-0")
|
||||||
|
.getByRole("switch", { name: /대표 조직/i })
|
||||||
|
.boundingBox();
|
||||||
|
const managerSwitchBox = await page
|
||||||
|
.getByTestId("detail-appointment-row-0")
|
||||||
|
.getByRole("switch", { name: /조직장/i })
|
||||||
|
.boundingBox();
|
||||||
|
|
||||||
|
if (!tenantPickerBox || !ownerSwitchBox || !managerSwitchBox) {
|
||||||
|
throw new Error("Appointment tenant owner controls are not visible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerYs = [
|
||||||
|
tenantPickerBox.y + tenantPickerBox.height / 2,
|
||||||
|
ownerSwitchBox.y + ownerSwitchBox.height / 2,
|
||||||
|
managerSwitchBox.y + managerSwitchBox.height / 2,
|
||||||
|
];
|
||||||
|
expect(Math.max(...centerYs) - Math.min(...centerYs)).toBeLessThan(8);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("detail-appointment-row-0").getByRole("switch", {
|
page.getByTestId("detail-appointment-row-0").getByRole("switch", {
|
||||||
name: /대표 조직/i,
|
name: /대표 조직/i,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
const method = route.request().method();
|
const method = route.request().method();
|
||||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
const isWorksmobileTenantPath = (suffix: string) =>
|
||||||
|
url.pathname.endsWith(`/admin/tenants/hanmac-family-id${suffix}`) ||
|
||||||
|
url.pathname.endsWith(
|
||||||
|
`/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a${suffix}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (url.pathname.endsWith("/user/me")) {
|
if (url.pathname.endsWith("/user/me")) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
@@ -45,7 +50,14 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
id: "admin-user",
|
id: "admin-user",
|
||||||
name: "Admin",
|
name: "Admin",
|
||||||
role: "super_admin",
|
role: "super_admin",
|
||||||
manageableTenants: [],
|
manageableTenants: [
|
||||||
|
{
|
||||||
|
id: "038326b6-954a-48a7-a85f-efd83f62b82a",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -68,10 +80,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isWorksmobileTenantPath("/worksmobile") && method === "GET") {
|
||||||
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
|
|
||||||
method === "GET"
|
|
||||||
) {
|
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
tenant: {
|
tenant: {
|
||||||
@@ -95,18 +104,14 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
isWorksmobileTenantPath("/worksmobile/credential-batches") &&
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
|
|
||||||
) &&
|
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
return route.fulfill({ json: [], headers });
|
return route.fulfill({ json: [], headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
isWorksmobileTenantPath("/worksmobile/comparison") &&
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
|
|
||||||
) &&
|
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
const includeMatched =
|
const includeMatched =
|
||||||
@@ -210,9 +215,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
isWorksmobileTenantPath("/worksmobile/users/user-missing/sync") &&
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/users/user-missing/sync",
|
|
||||||
) &&
|
|
||||||
method === "POST"
|
method === "POST"
|
||||||
) {
|
) {
|
||||||
syncRequests.push("user-missing");
|
syncRequests.push("user-missing");
|
||||||
@@ -225,24 +228,51 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/tenants/hanmac-family-id");
|
await page.goto("/");
|
||||||
await page.getByRole("link", { name: "Worksmobile" }).click();
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Worksmobile" }),
|
||||||
|
).toHaveAttribute("href", "/worksmobile");
|
||||||
|
await page.goto("/worksmobile");
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/tenants\/hanmac-family-id\/worksmobile$/);
|
await expect(page).toHaveURL(/\/worksmobile$/);
|
||||||
await expect(page.getByText("Baron / Works 비교")).toBeVisible();
|
await expect(page.getByRole("tab", { name: "이력" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: "사용자" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: "조직" })).toBeVisible();
|
||||||
|
await expect(page.getByText("비밀번호 파일 히스토리")).toBeVisible();
|
||||||
await expect(page.getByText("domainMappings")).not.toBeVisible();
|
await expect(page.getByText("domainMappings")).not.toBeVisible();
|
||||||
await expect(page.getByText("SCIM token")).not.toBeVisible();
|
await expect(page.getByText("SCIM token")).not.toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "사용자" }).click();
|
||||||
|
await expect(page.getByText("사용자 단건 동기화")).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder("Kratos user UUID")).toBeVisible();
|
||||||
|
const userSyncCard = page.getByTestId("worksmobile-users-single-sync");
|
||||||
|
const userComparisonTable = page.getByTestId(
|
||||||
|
"worksmobile-구성원-virtual-body",
|
||||||
|
);
|
||||||
|
await expect(userComparisonTable).toBeVisible();
|
||||||
|
await expect(userSyncCard).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const table = document.querySelector(
|
||||||
|
'[data-testid="worksmobile-구성원-virtual-body"]',
|
||||||
|
);
|
||||||
|
const sync = document.querySelector(
|
||||||
|
'[data-testid="worksmobile-users-single-sync"]',
|
||||||
|
);
|
||||||
|
return Boolean(
|
||||||
|
table &&
|
||||||
|
sync &&
|
||||||
|
table.compareDocumentPosition(sync) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
await expect(page.getByText("김누락")).toBeVisible();
|
await expect(page.getByText("김누락")).toBeVisible();
|
||||||
await expect(page.getByText("박웍스")).toBeVisible();
|
await expect(page.getByText("박웍스")).toBeVisible();
|
||||||
await expect(page.getByText("숨김 SU")).not.toBeVisible();
|
await expect(page.getByText("숨김 SU")).not.toBeVisible();
|
||||||
await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible();
|
await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible();
|
||||||
await expect(page.getByText("su-@samaneng.com")).not.toBeVisible();
|
await expect(page.getByText("su-@samaneng.com")).not.toBeVisible();
|
||||||
await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible();
|
await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible();
|
||||||
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
|
await expect(page.getByText("WORKS 전용 조직")).not.toBeVisible();
|
||||||
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("WORKS 기술본부")).toBeVisible();
|
|
||||||
await expect(page.getByText("works-parent-tech")).toBeVisible();
|
|
||||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||||
expect(comparisonRequests[0]).toBe(true);
|
expect(comparisonRequests[0]).toBe(true);
|
||||||
|
|
||||||
@@ -321,6 +351,37 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
||||||
.click();
|
.click();
|
||||||
await expect.poll(() => syncRequests).toEqual(["user-missing"]);
|
await expect.poll(() => syncRequests).toEqual(["user-missing"]);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "조직" }).click();
|
||||||
|
await expect(page.getByText("조직 단건 동기화")).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder("orgUnit tenant UUID")).toBeVisible();
|
||||||
|
const groupSyncCard = page.getByTestId("worksmobile-groups-single-sync");
|
||||||
|
const groupComparisonTable = page.getByTestId(
|
||||||
|
"worksmobile-조직/그룹-virtual-body",
|
||||||
|
);
|
||||||
|
await expect(groupComparisonTable).toBeVisible();
|
||||||
|
await expect(groupSyncCard).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const table = document.querySelector(
|
||||||
|
'[data-testid="worksmobile-조직/그룹-virtual-body"]',
|
||||||
|
);
|
||||||
|
const sync = document.querySelector(
|
||||||
|
'[data-testid="worksmobile-groups-single-sync"]',
|
||||||
|
);
|
||||||
|
return Boolean(
|
||||||
|
table &&
|
||||||
|
sync &&
|
||||||
|
table.compareDocumentPosition(sync) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
|
||||||
|
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("WORKS 기술본부")).toBeVisible();
|
||||||
|
await expect(page.getByText("works-parent-tech")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows a toast when selected WORKS creation fails", async ({ page }) => {
|
test("shows a toast when selected WORKS creation fails", async ({ page }) => {
|
||||||
@@ -352,7 +413,9 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile",
|
||||||
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
@@ -372,7 +435,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -381,7 +444,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -403,7 +466,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/users/user-fail/sync",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-fail/sync",
|
||||||
) &&
|
) &&
|
||||||
method === "POST"
|
method === "POST"
|
||||||
) {
|
) {
|
||||||
@@ -417,7 +480,8 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/tenants/hanmac-family-id/worksmobile");
|
await page.goto("/worksmobile");
|
||||||
|
await page.getByRole("tab", { name: "사용자" }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("row", { name: /실패 사용자/ })
|
.getByRole("row", { name: /실패 사용자/ })
|
||||||
.getByRole("checkbox")
|
.getByRole("checkbox")
|
||||||
@@ -465,7 +529,9 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile",
|
||||||
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
@@ -487,7 +553,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -496,7 +562,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -537,7 +603,8 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/tenants/hanmac-family-id/worksmobile");
|
await page.goto("/worksmobile");
|
||||||
|
await page.getByRole("tab", { name: "사용자" }).click();
|
||||||
await expect(page.getByText("긴 WORKS 사용자")).toBeVisible();
|
await expect(page.getByText("긴 WORKS 사용자")).toBeVisible();
|
||||||
|
|
||||||
const userColumnButton = page
|
const userColumnButton = page
|
||||||
@@ -608,7 +675,9 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile",
|
||||||
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
@@ -664,6 +733,24 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "job-pending",
|
||||||
|
resourceType: "ORGUNIT",
|
||||||
|
resourceId: "org-pending",
|
||||||
|
action: "UPSERT",
|
||||||
|
status: "pending",
|
||||||
|
retryCount: 0,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:01:00Z",
|
||||||
|
payload: {
|
||||||
|
matchLocalPart: "halla-site",
|
||||||
|
requestSummary: {
|
||||||
|
orgUnitName: "한라 현장",
|
||||||
|
email: "halla-site@hallasanup.com",
|
||||||
|
orgUnitExternalKey: "org-pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
@@ -672,7 +759,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -696,7 +783,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -708,7 +795,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/initial-passwords.csv",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/initial-passwords.csv",
|
||||||
) &&
|
) &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
@@ -726,7 +813,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/backfill/dry-run",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/backfill/dry-run",
|
||||||
) &&
|
) &&
|
||||||
method === "POST"
|
method === "POST"
|
||||||
) {
|
) {
|
||||||
@@ -736,7 +823,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/orgunits/org-1/sync",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/orgunits/org-1/sync",
|
||||||
) &&
|
) &&
|
||||||
method === "POST"
|
method === "POST"
|
||||||
) {
|
) {
|
||||||
@@ -746,7 +833,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/users/user-1/sync",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync",
|
||||||
) &&
|
) &&
|
||||||
method === "POST"
|
method === "POST"
|
||||||
) {
|
) {
|
||||||
@@ -756,7 +843,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname.endsWith(
|
url.pathname.endsWith(
|
||||||
"/admin/tenants/hanmac-family-id/worksmobile/jobs/job-retry/retry",
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/jobs/job-retry/retry",
|
||||||
) &&
|
) &&
|
||||||
method === "POST"
|
method === "POST"
|
||||||
) {
|
) {
|
||||||
@@ -764,10 +851,20 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
return route.fulfill({ json: { id: "job-retry-next" }, headers });
|
return route.fulfill({ json: { id: "job-retry-next" }, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/jobs/pending",
|
||||||
|
) &&
|
||||||
|
method === "DELETE"
|
||||||
|
) {
|
||||||
|
requests.push("delete-pending");
|
||||||
|
return route.fulfill({ json: { deletedCount: 1 }, headers });
|
||||||
|
}
|
||||||
|
|
||||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/tenants/hanmac-family-id/worksmobile");
|
await page.goto("/worksmobile");
|
||||||
await expect(page.getByText("Worksmobile 연동")).toBeVisible();
|
await expect(page.getByText("Worksmobile 연동")).toBeVisible();
|
||||||
|
|
||||||
const download = page.waitForEvent("download");
|
const download = page.waitForEvent("download");
|
||||||
@@ -785,27 +882,41 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
await page.getByRole("button", { name: "Backfill Dry-run" }).click();
|
await page.getByRole("button", { name: "Backfill Dry-run" }).click();
|
||||||
await expect.poll(() => requests).toContain("dry-run");
|
await expect.poll(() => requests).toContain("dry-run");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "조직" }).click();
|
||||||
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
|
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
|
||||||
await page.getByRole("button", { name: "조직 Sync" }).click();
|
await page.getByRole("button", { name: "조직 Sync" }).click();
|
||||||
await expect.poll(() => requests).toContain("org-sync");
|
await expect.poll(() => requests).toContain("org-sync");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "사용자" }).click();
|
||||||
await page.getByPlaceholder("Kratos user UUID").fill("user-1");
|
await page.getByPlaceholder("Kratos user UUID").fill("user-1");
|
||||||
await page.getByRole("button", { name: "구성원 Sync" }).click();
|
await page.getByRole("button", { name: "구성원 Sync" }).click();
|
||||||
await expect.poll(() => requests).toContain("user-sync");
|
await expect.poll(() => requests).toContain("user-sync");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "이력" }).click();
|
||||||
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
|
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
|
||||||
"changed-user@example.com",
|
"changed-user@example.com",
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("row", { name: /ORGUNIT:people-growth/ }),
|
page.getByRole("row", { name: /ORGUNIT:people-growth/ }),
|
||||||
).toContainText("people-growth@example.com");
|
).toContainText("people-growth@example.com");
|
||||||
await expect(page.getByText("externalKey:parent-org")).toBeVisible();
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("row", { name: /ORGUNIT:people-growth/ })
|
||||||
|
.getByText("externalKey:parent-org")
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
|
||||||
|
await failedJobRow.getByText("payload").click();
|
||||||
|
await expect(
|
||||||
|
failedJobRow.getByText('"loginEmail": "changed-user@example.com"'),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await page
|
await failedJobRow.getByRole("button").click();
|
||||||
.getByRole("row", { name: /변경 사용자/ })
|
|
||||||
.getByRole("button")
|
|
||||||
.click();
|
|
||||||
await expect.poll(() => requests).toContain("retry");
|
await expect.poll(() => requests).toContain("retry");
|
||||||
|
|
||||||
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.getByRole("button", { name: /대기중 payload 삭제/ }).click();
|
||||||
|
await expect.poll(() => requests).toContain("delete-pending");
|
||||||
expect(requests).toContain("download-passwords");
|
expect(requests).toContain("download-passwords");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
||||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
|
userGroupService.SetWorksmobileSyncer(worksmobileService)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
|
|
||||||
hydraService := service.NewHydraAdminService()
|
hydraService := service.NewHydraAdminService()
|
||||||
@@ -759,6 +760,7 @@ func main() {
|
|||||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
||||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
|
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
|
||||||
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
||||||
|
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)
|
||||||
|
|
||||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||||
org := admin.Group("/tenants/:tenantId/organization")
|
org := admin.Group("/tenants/:tenantId/organization")
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type InitialTenantConfig struct {
|
|||||||
ParentSlug string
|
ParentSlug string
|
||||||
Description string
|
Description string
|
||||||
Domains []string
|
Domains []string
|
||||||
|
Config domain.JSONMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func SeedTenants(db *gorm.DB) error {
|
func SeedTenants(db *gorm.DB) error {
|
||||||
@@ -149,6 +150,9 @@ func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tenant.Status = domain.TenantStatusActive
|
tenant.Status = domain.TenantStatusActive
|
||||||
|
if len(config.Config) > 0 {
|
||||||
|
tenant.Config = config.Config
|
||||||
|
}
|
||||||
if err := db.Save(tenant).Error; err != nil {
|
if err := db.Save(tenant).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -265,6 +269,11 @@ func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
|
|||||||
return nil, fmt.Errorf("row %d: slug is required", i+2)
|
return nil, fmt.Errorf("row %d: slug is required", i+2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config, err := seedTenantCSVRecordConfig(row, header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("row %d: %w", i+2, err)
|
||||||
|
}
|
||||||
|
|
||||||
configs = append(configs, InitialTenantConfig{
|
configs = append(configs, InitialTenantConfig{
|
||||||
TenantID: seedTenantCSVValue(row, header, "tenant_id"),
|
TenantID: seedTenantCSVValue(row, header, "tenant_id"),
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -273,6 +282,7 @@ func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
|
|||||||
Slug: slug,
|
Slug: slug,
|
||||||
Description: seedTenantCSVValue(row, header, "memo"),
|
Description: seedTenantCSVValue(row, header, "memo"),
|
||||||
Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")),
|
Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")),
|
||||||
|
Config: config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +308,18 @@ func seedTenantCSVHeaderIndex(header []string) map[string]int {
|
|||||||
"email_domain": "email_domain",
|
"email_domain": "email_domain",
|
||||||
"domain": "email_domain",
|
"domain": "email_domain",
|
||||||
"domains": "email_domain",
|
"domains": "email_domain",
|
||||||
|
"visibility": "visibility",
|
||||||
|
"public_setting": "visibility",
|
||||||
|
"publicsetting": "visibility",
|
||||||
|
"org_unit_type": "org_unit_type",
|
||||||
|
"orgunittype": "org_unit_type",
|
||||||
|
"organization_type": "org_unit_type",
|
||||||
|
"organizationtype": "org_unit_type",
|
||||||
|
"worksmobile": "worksmobile_sync",
|
||||||
|
"worksmobilesync": "worksmobile_sync",
|
||||||
|
"worksmobile_sync": "worksmobile_sync",
|
||||||
|
"works_sync": "worksmobile_sync",
|
||||||
|
"works": "worksmobile_sync",
|
||||||
}
|
}
|
||||||
for i, column := range header {
|
for i, column := range header {
|
||||||
key := strings.ToLower(strings.TrimSpace(column))
|
key := strings.ToLower(strings.TrimSpace(column))
|
||||||
@@ -317,6 +339,67 @@ func seedTenantCSVValue(row []string, header map[string]int, key string) string
|
|||||||
return strings.TrimSpace(row[idx])
|
return strings.TrimSpace(row[idx])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedTenantCSVRecordConfig(row []string, header map[string]int) (domain.JSONMap, error) {
|
||||||
|
config := domain.JSONMap{}
|
||||||
|
visibility := strings.TrimSpace(seedTenantCSVValue(row, header, "visibility"))
|
||||||
|
if visibility != "" {
|
||||||
|
normalizedVisibility, err := normalizeSeedTenantVisibility(visibility)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
config["visibility"] = normalizedVisibility
|
||||||
|
}
|
||||||
|
orgUnitType := strings.TrimSpace(seedTenantCSVValue(row, header, "org_unit_type"))
|
||||||
|
if orgUnitType != "" {
|
||||||
|
if !isAllowedSeedTenantOrgUnitType(orgUnitType) {
|
||||||
|
return nil, errors.New("orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속")
|
||||||
|
}
|
||||||
|
config["orgUnitType"] = orgUnitType
|
||||||
|
}
|
||||||
|
if worksmobileSync := strings.TrimSpace(seedTenantCSVValue(row, header, "worksmobile_sync")); worksmobileSync != "" {
|
||||||
|
excluded, err := normalizeSeedTenantWorksmobileExcluded(worksmobileSync)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
config["worksmobileExcluded"] = excluded
|
||||||
|
}
|
||||||
|
if len(config) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSeedTenantWorksmobileExcluded(value string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
|
||||||
|
return false, nil
|
||||||
|
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("worksmobile_sync must be yes or no")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSeedTenantVisibility(value string) (string, error) {
|
||||||
|
visibility := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if visibility == "" || visibility == "public" {
|
||||||
|
return "public", nil
|
||||||
|
}
|
||||||
|
if visibility != "internal" && visibility != "private" {
|
||||||
|
return "", errors.New("visibility must be public, internal, or private")
|
||||||
|
}
|
||||||
|
return visibility, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllowedSeedTenantOrgUnitType(value string) bool {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func seedTenantCSVRowIsEmpty(row []string) bool {
|
func seedTenantCSVRowIsEmpty(row []string) bool {
|
||||||
for _, value := range row {
|
for _, value := range row {
|
||||||
if strings.TrimSpace(value) != "" {
|
if strings.TrimSpace(value) != "" {
|
||||||
@@ -405,6 +488,7 @@ func createSeedTenant(
|
|||||||
Description: config.Description,
|
Description: config.Description,
|
||||||
Status: domain.TenantStatusActive,
|
Status: domain.TenantStatusActive,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
|
Config: config.Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.Create(ctx, tenant); err != nil {
|
if err := repo.Create(ctx, tenant); err != nil {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
|||||||
slug: "baron-group",
|
slug: "baron-group",
|
||||||
tenantType: domain.TenantTypeCompanyGroup,
|
tenantType: domain.TenantTypeCompanyGroup,
|
||||||
parentSlug: "hanmac-family",
|
parentSlug: "hanmac-family",
|
||||||
|
domains: []string{"brsw.kr"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "(주)장헌",
|
name: "(주)장헌",
|
||||||
@@ -78,10 +79,10 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "한라산업개발",
|
name: "한라산업개발",
|
||||||
slug: "hanlla",
|
slug: "halla",
|
||||||
tenantType: domain.TenantTypeCompany,
|
tenantType: domain.TenantTypeCompany,
|
||||||
parentSlug: "baron-group",
|
parentSlug: "hanmac-family",
|
||||||
domains: []string{"hanllasanup.co.kr"},
|
domains: []string{"hallasanup.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "(주)피티씨",
|
name: "(주)피티씨",
|
||||||
@@ -97,30 +98,64 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(configs) != len(expected) {
|
if len(configs) < len(expected) {
|
||||||
t.Fatalf("expected %d seed tenants, got %d", len(expected), len(configs))
|
t.Fatalf("expected at least %d seed tenants, got %d", len(expected), len(configs))
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, want := range expected {
|
wantFamilyChildOrder := []string{
|
||||||
got := configs[i]
|
"gpdtdc",
|
||||||
|
"saman",
|
||||||
|
"hanmac",
|
||||||
|
"baron-group",
|
||||||
|
"halla",
|
||||||
|
}
|
||||||
|
policyFamilyChildSlugs := map[string]bool{}
|
||||||
|
for _, slug := range wantFamilyChildOrder {
|
||||||
|
policyFamilyChildSlugs[slug] = true
|
||||||
|
}
|
||||||
|
gotFamilyChildOrder := make([]string, 0, len(wantFamilyChildOrder))
|
||||||
|
for _, config := range configs {
|
||||||
|
if config.ParentSlug == "hanmac-family" && policyFamilyChildSlugs[config.Slug] {
|
||||||
|
gotFamilyChildOrder = append(gotFamilyChildOrder, config.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(gotFamilyChildOrder) != len(wantFamilyChildOrder) {
|
||||||
|
t.Fatalf("hanmac-family child order = %#v, want %#v", gotFamilyChildOrder, wantFamilyChildOrder)
|
||||||
|
}
|
||||||
|
for i, wantSlug := range wantFamilyChildOrder {
|
||||||
|
if gotFamilyChildOrder[i] != wantSlug {
|
||||||
|
t.Fatalf("hanmac-family child order[%d] = %q, want %q", i, gotFamilyChildOrder[i], wantSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||||
|
for _, config := range configs {
|
||||||
|
configBySlug[config.Slug] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range expected {
|
||||||
|
got, ok := configBySlug[want.slug]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("tenant slug %q not found in seed configs", want.slug)
|
||||||
|
}
|
||||||
if got.Name != want.name {
|
if got.Name != want.name {
|
||||||
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
|
t.Fatalf("tenant[%s] name = %q, want %q", want.slug, got.Name, want.name)
|
||||||
}
|
}
|
||||||
if got.Slug != want.slug {
|
if got.Slug != want.slug {
|
||||||
t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug)
|
t.Fatalf("tenant[%s] slug = %q, want %q", want.slug, got.Slug, want.slug)
|
||||||
}
|
}
|
||||||
if got.Type != want.tenantType {
|
if got.Type != want.tenantType {
|
||||||
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
|
t.Fatalf("tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
|
||||||
}
|
}
|
||||||
if got.ParentSlug != want.parentSlug {
|
if got.ParentSlug != want.parentSlug {
|
||||||
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, got.ParentSlug, want.parentSlug)
|
t.Fatalf("tenant[%s] parent slug = %q, want %q", want.slug, got.ParentSlug, want.parentSlug)
|
||||||
}
|
}
|
||||||
if len(got.Domains) != len(want.domains) {
|
if len(got.Domains) != len(want.domains) {
|
||||||
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
|
t.Fatalf("tenant[%s] domains = %#v, want %#v", want.slug, got.Domains, want.domains)
|
||||||
}
|
}
|
||||||
for j, wantDomain := range want.domains {
|
for j, wantDomain := range want.domains {
|
||||||
if got.Domains[j] != wantDomain {
|
if got.Domains[j] != wantDomain {
|
||||||
t.Fatalf("tenant[%d] domain[%d] = %q, want %q", i, j, got.Domains[j], wantDomain)
|
t.Fatalf("tenant[%s] domain[%d] = %q, want %q", want.slug, j, got.Domains[j], wantDomain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,9 +170,9 @@ func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
|
|||||||
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "seed-tenant.csv")
|
path := filepath.Join(dir, "seed-tenant.csv")
|
||||||
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
csv := "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
|
||||||
"Root,COMPANY_GROUP,,root,Root memo,\n" +
|
"Root,COMPANY_GROUP,,root,Root memo,,,,\n" +
|
||||||
"Child,COMPANY,root,child,Child memo,child.example.com\n"
|
"Child,USER_GROUP,root,child,Child memo,child.example.com,private,팀,no\n"
|
||||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||||
t.Fatalf("failed to write seed csv: %v", err)
|
t.Fatalf("failed to write seed csv: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,6 +191,41 @@ func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
|||||||
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
|
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
|
||||||
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
|
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
|
||||||
}
|
}
|
||||||
|
if configs[1].Config["visibility"] != "private" {
|
||||||
|
t.Fatalf("child visibility = %#v, want private", configs[1].Config["visibility"])
|
||||||
|
}
|
||||||
|
if configs[1].Config["orgUnitType"] != "팀" {
|
||||||
|
t.Fatalf("child orgUnitType = %#v, want 팀", configs[1].Config["orgUnitType"])
|
||||||
|
}
|
||||||
|
if configs[1].Config["worksmobileExcluded"] != true {
|
||||||
|
t.Fatalf("child worksmobileExcluded = %#v, want true", configs[1].Config["worksmobileExcluded"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedTenantCSVDefinesMHDAsPrivateUserGroup(t *testing.T) {
|
||||||
|
configs, err := loadSeedTenantConfigs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||||
|
for _, config := range configs {
|
||||||
|
configBySlug[config.Slug] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
mhd, ok := configBySlug["mhd"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mhd seed tenant not found")
|
||||||
|
}
|
||||||
|
if mhd.Type != domain.TenantTypeUserGroup {
|
||||||
|
t.Fatalf("mhd type = %q, want %q", mhd.Type, domain.TenantTypeUserGroup)
|
||||||
|
}
|
||||||
|
if mhd.Config["visibility"] != "private" {
|
||||||
|
t.Fatalf("mhd visibility = %#v, want private", mhd.Config["visibility"])
|
||||||
|
}
|
||||||
|
if mhd.Config["worksmobileExcluded"] != true {
|
||||||
|
t.Fatalf("mhd worksmobileExcluded = %#v, want true", mhd.Config["worksmobileExcluded"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
|
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
|
||||||
|
|||||||
@@ -117,16 +117,18 @@ type tenantDomainConflict struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tenantCSVRecord struct {
|
type tenantCSVRecord struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
Name string
|
Name string
|
||||||
Type string
|
Type string
|
||||||
ParentTenantID *string
|
ParentTenantID *string
|
||||||
ParentTenantSlug string
|
ParentTenantSlug string
|
||||||
Slug string
|
Slug string
|
||||||
Memo string
|
Memo string
|
||||||
Domains []string
|
Domains []string
|
||||||
Visibility string
|
Visibility string
|
||||||
OrgUnitType string
|
OrgUnitType string
|
||||||
|
WorksmobileSync string
|
||||||
|
WorksmobileSyncSet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type orgContextTenant struct {
|
type orgContextTenant struct {
|
||||||
@@ -420,10 +422,10 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
writer := csv.NewWriter(&buf)
|
writer := csv.NewWriter(&buf)
|
||||||
includeIDs := includeCSVIds(c)
|
includeIDs := includeCSVIds(c)
|
||||||
if includeIDs {
|
if includeIDs {
|
||||||
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil {
|
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil {
|
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
slugByID := make(map[string]string, len(allTenants))
|
slugByID := make(map[string]string, len(allTenants))
|
||||||
@@ -444,7 +446,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
domains = append(domains, domainName)
|
domains = append(domains, domainName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config)
|
visibility, orgUnitType, worksmobileSync := tenantCSVOrgConfigValues(tenant.Config)
|
||||||
row := []string{
|
row := []string{
|
||||||
tenant.Name,
|
tenant.Name,
|
||||||
tenant.Type,
|
tenant.Type,
|
||||||
@@ -454,6 +456,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
strings.Join(domains, ";"),
|
strings.Join(domains, ";"),
|
||||||
visibility,
|
visibility,
|
||||||
orgUnitType,
|
orgUnitType,
|
||||||
|
worksmobileSync,
|
||||||
}
|
}
|
||||||
if includeIDs {
|
if includeIDs {
|
||||||
row = []string{
|
row = []string{
|
||||||
@@ -467,6 +470,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
strings.Join(domains, ";"),
|
strings.Join(domains, ";"),
|
||||||
visibility,
|
visibility,
|
||||||
orgUnitType,
|
orgUnitType,
|
||||||
|
worksmobileSync,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writer.Write(row); err != nil {
|
if err := writer.Write(row); err != nil {
|
||||||
@@ -683,17 +687,20 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
|||||||
parentID = &parentValue
|
parentID = &parentValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
worksmobileSync, worksmobileSyncSet := tenantCSVWorksmobileSyncValue(row, header)
|
||||||
records = append(records, tenantCSVRecord{
|
records = append(records, tenantCSVRecord{
|
||||||
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: tenantType,
|
Type: tenantType,
|
||||||
ParentTenantID: parentID,
|
ParentTenantID: parentID,
|
||||||
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
|
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Memo: tenantCSVValue(row, header, "memo"),
|
Memo: tenantCSVValue(row, header, "memo"),
|
||||||
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
||||||
Visibility: tenantCSVValue(row, header, "visibility"),
|
Visibility: tenantCSVValue(row, header, "visibility"),
|
||||||
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
|
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
|
||||||
|
WorksmobileSync: worksmobileSync,
|
||||||
|
WorksmobileSyncSet: worksmobileSyncSet,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,35 +710,42 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
|||||||
func tenantCSVHeaderIndex(header []string) map[string]int {
|
func tenantCSVHeaderIndex(header []string) map[string]int {
|
||||||
index := make(map[string]int, len(header))
|
index := make(map[string]int, len(header))
|
||||||
aliases := map[string]string{
|
aliases := map[string]string{
|
||||||
"id": "tenant_id",
|
"id": "tenant_id",
|
||||||
"tenantid": "tenant_id",
|
"tenantid": "tenant_id",
|
||||||
"tenant_id": "tenant_id",
|
"tenant_id": "tenant_id",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type": "type",
|
"type": "type",
|
||||||
"parentid": "parent_tenant_id",
|
"parentid": "parent_tenant_id",
|
||||||
"parent_id": "parent_tenant_id",
|
"parent_id": "parent_tenant_id",
|
||||||
"parenttenantid": "parent_tenant_id",
|
"parenttenantid": "parent_tenant_id",
|
||||||
"parent_tenant_id": "parent_tenant_id",
|
"parent_tenant_id": "parent_tenant_id",
|
||||||
"parenttenantslug": "parent_tenant_slug",
|
"parenttenantslug": "parent_tenant_slug",
|
||||||
"parent_tenant_slug": "parent_tenant_slug",
|
"parent_tenant_slug": "parent_tenant_slug",
|
||||||
"slug": "slug",
|
"slug": "slug",
|
||||||
"memo": "memo",
|
"memo": "memo",
|
||||||
"description": "memo",
|
"description": "memo",
|
||||||
"email-domain": "email_domain",
|
"email-domain": "email_domain",
|
||||||
"emaildomain": "email_domain",
|
"emaildomain": "email_domain",
|
||||||
"email_domain": "email_domain",
|
"email_domain": "email_domain",
|
||||||
"domain": "email_domain",
|
"domain": "email_domain",
|
||||||
"domains": "email_domain",
|
"domains": "email_domain",
|
||||||
"visibility": "visibility",
|
"visibility": "visibility",
|
||||||
"public_setting": "visibility",
|
"public_setting": "visibility",
|
||||||
"publicsetting": "visibility",
|
"publicsetting": "visibility",
|
||||||
"orgunittype": "org_unit_type",
|
"orgunittype": "org_unit_type",
|
||||||
"org_unit_type": "org_unit_type",
|
"org_unit_type": "org_unit_type",
|
||||||
"org-unit-type": "org_unit_type",
|
"org-unit-type": "org_unit_type",
|
||||||
"organizationtype": "org_unit_type",
|
"organizationtype": "org_unit_type",
|
||||||
"organization_type": "org_unit_type",
|
"organization_type": "org_unit_type",
|
||||||
"orgtype": "org_unit_type",
|
"orgtype": "org_unit_type",
|
||||||
"org_type": "org_unit_type",
|
"org_type": "org_unit_type",
|
||||||
|
"worksmobile": "worksmobile_sync",
|
||||||
|
"worksmobilesync": "worksmobile_sync",
|
||||||
|
"worksmobile_sync": "worksmobile_sync",
|
||||||
|
"works_sync": "worksmobile_sync",
|
||||||
|
"works": "worksmobile_sync",
|
||||||
|
"worksmobileexcluded": "worksmobile_excluded",
|
||||||
|
"worksmobile_excluded": "worksmobile_excluded",
|
||||||
}
|
}
|
||||||
for i, column := range header {
|
for i, column := range header {
|
||||||
key := strings.ToLower(strings.TrimSpace(column))
|
key := strings.ToLower(strings.TrimSpace(column))
|
||||||
@@ -751,6 +765,28 @@ func tenantCSVValue(row []string, header map[string]int, key string) string {
|
|||||||
return strings.TrimSpace(row[idx])
|
return strings.TrimSpace(row[idx])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tenantCSVWorksmobileSyncValue(row []string, header map[string]int) (string, bool) {
|
||||||
|
if _, ok := header["worksmobile_sync"]; ok {
|
||||||
|
value := tenantCSVValue(row, header, "worksmobile_sync")
|
||||||
|
if value == "" {
|
||||||
|
return "yes", true
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
if _, ok := header["worksmobile_excluded"]; ok {
|
||||||
|
value := tenantCSVValue(row, header, "worksmobile_excluded")
|
||||||
|
excluded, err := normalizeTenantWorksmobileExcluded(value)
|
||||||
|
if err == nil && excluded {
|
||||||
|
return "no", true
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return "yes", true
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func tenantCSVRowIsEmpty(row []string) bool {
|
func tenantCSVRowIsEmpty(row []string) bool {
|
||||||
for _, value := range row {
|
for _, value := range row {
|
||||||
if strings.TrimSpace(value) != "" {
|
if strings.TrimSpace(value) != "" {
|
||||||
@@ -872,11 +908,38 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
|
|||||||
normalized[key] = orgUnitType
|
normalized[key] = orgUnitType
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if key == "worksmobileExcluded" {
|
||||||
|
excluded, err := normalizeTenantWorksmobileExcluded(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized[key] = excluded
|
||||||
|
continue
|
||||||
|
}
|
||||||
normalized[key] = value
|
normalized[key] = value
|
||||||
}
|
}
|
||||||
return normalized, nil
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeTenantWorksmobileExcluded(value any) (bool, error) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case bool:
|
||||||
|
return typed, nil
|
||||||
|
case string:
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(typed))
|
||||||
|
switch normalized {
|
||||||
|
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
|
||||||
|
return false, nil
|
||||||
|
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("worksmobile_sync must be yes or no")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("worksmobile_sync must be yes or no")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isAllowedOrgUnitType(value string) bool {
|
func isAllowedOrgUnitType(value string) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
|
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
|
||||||
@@ -948,10 +1011,14 @@ func tenantVisibility(config domain.JSONMap) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string) {
|
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string, string) {
|
||||||
visibility := tenantVisibility(config)
|
visibility := tenantVisibility(config)
|
||||||
orgUnitType, _ := config["orgUnitType"].(string)
|
orgUnitType, _ := config["orgUnitType"].(string)
|
||||||
return visibility, strings.TrimSpace(orgUnitType)
|
worksmobileSync := "yes"
|
||||||
|
if excluded, err := normalizeTenantWorksmobileExcluded(config["worksmobileExcluded"]); err == nil && excluded {
|
||||||
|
worksmobileSync = "no"
|
||||||
|
}
|
||||||
|
return visibility, strings.TrimSpace(orgUnitType), worksmobileSync
|
||||||
}
|
}
|
||||||
|
|
||||||
func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
|
func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
|
||||||
@@ -962,6 +1029,9 @@ func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
|
|||||||
if strings.TrimSpace(record.OrgUnitType) != "" {
|
if strings.TrimSpace(record.OrgUnitType) != "" {
|
||||||
config["orgUnitType"] = record.OrgUnitType
|
config["orgUnitType"] = record.OrgUnitType
|
||||||
}
|
}
|
||||||
|
if record.WorksmobileSyncSet {
|
||||||
|
config["worksmobileExcluded"] = record.WorksmobileSync
|
||||||
|
}
|
||||||
if len(config) == 0 {
|
if len(config) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -2319,7 +2389,7 @@ func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
|
|||||||
for _, domain := range tenant.Domains {
|
for _, domain := range tenant.Domains {
|
||||||
domains = append(domains, domain.Domain)
|
domains = append(domains, domain.Domain)
|
||||||
}
|
}
|
||||||
visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config)
|
visibility, orgUnitType, _ := tenantCSVOrgConfigValues(tenant.Config)
|
||||||
return orgContextTenant{
|
return orgContextTenant{
|
||||||
ID: tenant.ID,
|
ID: tenant.ID,
|
||||||
Type: tenant.Type,
|
Type: tenant.Type,
|
||||||
|
|||||||
@@ -991,8 +991,8 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
|
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
|
||||||
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||||
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
|
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
|
||||||
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터")
|
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터,yes")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) {
|
func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) {
|
||||||
@@ -1027,7 +1027,7 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
|
|||||||
text := string(body)
|
text := string(body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
|
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
|
||||||
assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,")
|
assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,")
|
||||||
assert.NotContains(t, text, "tenant_id")
|
assert.NotContains(t, text, "tenant_id")
|
||||||
assert.NotContains(t, text, "parent_tenant_id")
|
assert.NotContains(t, text, "parent_tenant_id")
|
||||||
@@ -1114,7 +1114,7 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
|
|||||||
text := string(body)
|
text := string(body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
|
assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
|
||||||
assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,")
|
assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,")
|
||||||
assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,")
|
assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,")
|
||||||
assert.NotContains(t, text, unrelatedID)
|
assert.NotContains(t, text, unrelatedID)
|
||||||
@@ -1309,8 +1309,8 @@ func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
|
|||||||
|
|
||||||
func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
|
func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
|
||||||
records, err := parseTenantCSVRecords(strings.NewReader(
|
records, err := parseTenantCSVRecords(strings.NewReader(
|
||||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n" +
|
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
|
||||||
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터\n",
|
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터,no\n",
|
||||||
))
|
))
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -1318,6 +1318,10 @@ func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains)
|
assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains)
|
||||||
assert.Equal(t, "internal", records[0].Visibility)
|
assert.Equal(t, "internal", records[0].Visibility)
|
||||||
assert.Equal(t, "센터", records[0].OrgUnitType)
|
assert.Equal(t, "센터", records[0].OrgUnitType)
|
||||||
|
assert.Equal(t, "no", records[0].WorksmobileSync)
|
||||||
|
config, err := tenantCSVRecordConfig(records[0])
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, true, config["worksmobileExcluded"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) {
|
func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) {
|
||||||
@@ -1378,13 +1382,15 @@ 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": "센터",
|
||||||
|
"worksmobileExcluded": true,
|
||||||
})
|
})
|
||||||
|
|
||||||
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"])
|
||||||
|
assert.Equal(t, true, config["worksmobileExcluded"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {
|
func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -53,6 +54,8 @@ func (m *MockUserGroupService) List(ctx context.Context, tenantID string) ([]dom
|
|||||||
return args.Get(0).([]domain.UserGroup), args.Error(1)
|
return args.Get(0).([]domain.UserGroup), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockUserGroupService) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {}
|
||||||
|
|
||||||
func (m *MockUserGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
func (m *MockUserGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||||
return m.Called(ctx, groupID, userID).Error(0)
|
return m.Called(ctx, groupID, userID).Error(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,70 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
|
||||||
|
if tenantService == nil || metadata == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared := false
|
||||||
|
clearMetadataPrimary := func() {
|
||||||
|
delete(metadata, "primaryTenantId")
|
||||||
|
delete(metadata, "primaryTenantSlug")
|
||||||
|
delete(metadata, "primaryTenantName")
|
||||||
|
delete(metadata, "primaryTenantIsOwner")
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
|
||||||
|
clearMetadataPrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAppointment := func(appointment map[string]any) {
|
||||||
|
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := normalizeMetadataString(appointment["tenantId"])
|
||||||
|
tenantSlug := normalizeMetadataString(appointment["tenantSlug"])
|
||||||
|
if tenantSlug == "" {
|
||||||
|
tenantSlug = normalizeMetadataString(appointment["slug"])
|
||||||
|
}
|
||||||
|
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appointment["isPrimary"] = false
|
||||||
|
appointment["primary"] = false
|
||||||
|
appointment["representative"] = false
|
||||||
|
appointment["isRepresentative"] = false
|
||||||
|
clearMetadataPrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, appointment := range appointments {
|
||||||
|
clearAppointment(appointment)
|
||||||
|
}
|
||||||
|
if rawAppointments, ok := metadata["additionalAppointments"].([]any); ok {
|
||||||
|
for _, rawAppointment := range rawAppointments {
|
||||||
|
if appointment, ok := rawAppointment.(map[string]any); ok {
|
||||||
|
clearAppointment(appointment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleared, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
|
||||||
|
var tenant *domain.Tenant
|
||||||
|
var err error
|
||||||
|
if strings.TrimSpace(tenantID) != "" {
|
||||||
|
tenant, err = tenantService.GetTenant(ctx, strings.TrimSpace(tenantID))
|
||||||
|
} else if strings.TrimSpace(tenantSlug) != "" {
|
||||||
|
tenant, err = tenantService.GetTenantBySlug(ctx, strings.TrimSpace(tenantSlug))
|
||||||
|
}
|
||||||
|
if err != nil || tenant == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
visibility := tenantVisibility(tenant.Config)
|
||||||
|
return visibility == "internal" || visibility == "private"
|
||||||
|
}
|
||||||
|
|
||||||
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
|
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
|
||||||
if value := strings.TrimSpace(primaryTenantID); value != "" {
|
if value := strings.TrimSpace(primaryTenantID); value != "" {
|
||||||
return value
|
return value
|
||||||
@@ -651,6 +715,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
req.CompanyCode = tenantSlug
|
req.CompanyCode = tenantSlug
|
||||||
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
||||||
|
representativeCleared := false
|
||||||
|
if h.TenantService != nil {
|
||||||
|
cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
representativeCleared = cleared
|
||||||
|
if cleared {
|
||||||
|
req.PrimaryTenantID = ""
|
||||||
|
req.PrimaryTenantName = ""
|
||||||
|
req.PrimaryTenantIsOwner = nil
|
||||||
|
req.CompanyCode = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
email := strings.TrimSpace(req.Email)
|
email := strings.TrimSpace(req.Email)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
@@ -725,7 +803,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
||||||
var tenantID string
|
var tenantID string
|
||||||
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments)
|
primaryAppointments := req.AdditionalAppointments
|
||||||
|
if representativeCleared {
|
||||||
|
primaryAppointments = nil
|
||||||
|
}
|
||||||
|
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, primaryAppointments)
|
||||||
if req.CompanyCode == "" && h.TenantService != nil {
|
if req.CompanyCode == "" && h.TenantService != nil {
|
||||||
if requestedPrimaryTenantID != "" {
|
if requestedPrimaryTenantID != "" {
|
||||||
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
|
||||||
@@ -1995,6 +2077,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
req.CompanyCode = tenantSlug
|
req.CompanyCode = tenantSlug
|
||||||
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
||||||
|
if h.TenantService != nil {
|
||||||
|
cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
if cleared {
|
||||||
|
req.PrimaryTenantID = ""
|
||||||
|
req.PrimaryTenantName = ""
|
||||||
|
req.PrimaryTenantIsOwner = nil
|
||||||
|
req.CompanyCode = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.Role != nil {
|
if req.Role != nil {
|
||||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
||||||
|
|||||||
@@ -205,6 +205,49 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
|
|||||||
assert.Contains(t, metadata, "userType")
|
assert.Contains(t, metadata, "userType")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
internalTenantID := "internal-tenant"
|
||||||
|
publicTenantID := "public-tenant"
|
||||||
|
metadata := map[string]any{
|
||||||
|
"primaryTenantId": internalTenantID,
|
||||||
|
"primaryTenantName": "비공개팀",
|
||||||
|
"primaryTenantSlug": "private-team",
|
||||||
|
"additionalAppointments": []any{
|
||||||
|
map[string]any{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
|
||||||
|
map[string]any{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
appointments := []map[string]any{
|
||||||
|
{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
|
||||||
|
{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockTenant.On("GetTenant", mock.Anything, internalTenantID).Return(&domain.Tenant{
|
||||||
|
ID: internalTenantID,
|
||||||
|
Slug: "private-team",
|
||||||
|
Config: domain.JSONMap{"visibility": "private"},
|
||||||
|
}, nil)
|
||||||
|
mockTenant.On("GetTenant", mock.Anything, publicTenantID).Return(&domain.Tenant{
|
||||||
|
ID: publicTenantID,
|
||||||
|
Slug: "public-team",
|
||||||
|
Config: domain.JSONMap{"visibility": "public"},
|
||||||
|
}, nil).Maybe()
|
||||||
|
|
||||||
|
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cleared)
|
||||||
|
assert.NotContains(t, metadata, "primaryTenantId")
|
||||||
|
assert.NotContains(t, metadata, "primaryTenantName")
|
||||||
|
assert.NotContains(t, metadata, "primaryTenantSlug")
|
||||||
|
assert.Equal(t, false, appointments[0]["isPrimary"])
|
||||||
|
metadataAppointments := metadata["additionalAppointments"].([]any)
|
||||||
|
firstAppointment := metadataAppointments[0].(map[string]any)
|
||||||
|
assert.Equal(t, false, firstAppointment["isPrimary"])
|
||||||
|
mockTenant.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
type MockTenantServiceForUser struct {
|
type MockTenantServiceForUser struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
service.TenantService
|
service.TenantService
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
|||||||
return c.JSON(job)
|
return c.JSON(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WorksmobileHandler) DeletePendingJobs(c *fiber.Ctx) error {
|
||||||
|
result, err := h.Service.DeletePendingJobs(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||||
|
if err != nil {
|
||||||
|
return worksmobileGuardError(c, err, "delete_pending_jobs")
|
||||||
|
}
|
||||||
|
return c.JSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
|
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -153,6 +153,24 @@ func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
|
|||||||
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
|
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHandlerDeletesPendingJobs(t *testing.T) {
|
||||||
|
fakeService := &fakeWorksmobileAdminService{
|
||||||
|
pendingJobsDeleteResult: service.WorksmobilePendingJobDeleteResult{DeletedCount: 3},
|
||||||
|
}
|
||||||
|
h := NewWorksmobileHandler(fakeService)
|
||||||
|
app := fiber.New()
|
||||||
|
app.Delete("/tenants/:tenantId/worksmobile/jobs/pending", h.DeletePendingJobs)
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/jobs/pending", nil))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, "hanmac-id", fakeService.deletedPendingJobsTenantID)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(body), `"deletedCount":3`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||||
var logs bytes.Buffer
|
var logs bytes.Buffer
|
||||||
previous := slog.Default()
|
previous := slog.Default()
|
||||||
@@ -184,6 +202,8 @@ type fakeWorksmobileAdminService struct {
|
|||||||
resetPasswordCredentialBatchID string
|
resetPasswordCredentialBatchID string
|
||||||
downloadCredentialBatchID string
|
downloadCredentialBatchID string
|
||||||
deletedCredentialBatchID string
|
deletedCredentialBatchID string
|
||||||
|
deletedPendingJobsTenantID string
|
||||||
|
pendingJobsDeleteResult service.WorksmobilePendingJobDeleteResult
|
||||||
credentialBatches []service.WorksmobileCredentialBatch
|
credentialBatches []service.WorksmobileCredentialBatch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,3 +257,8 @@ func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context
|
|||||||
f.deletedCredentialBatchID = credentialBatchID
|
f.deletedCredentialBatchID = credentialBatchID
|
||||||
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
|
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileAdminService) DeletePendingJobs(ctx context.Context, tenantID string) (service.WorksmobilePendingJobDeleteResult, error) {
|
||||||
|
f.deletedPendingJobsTenantID = tenantID
|
||||||
|
return f.pendingJobsDeleteResult, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate
|
// Auto-migrate
|
||||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{})
|
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to migrate database: %s", err)
|
log.Fatalf("failed to migrate database: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ type WorksmobileOutboxRepository interface {
|
|||||||
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||||
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
|
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
|
||||||
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
|
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
|
||||||
|
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
|
||||||
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||||
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
||||||
MarkRetry(ctx context.Context, id string) error
|
MarkRetry(ctx context.Context, id string) error
|
||||||
MarkProcessing(ctx context.Context, id string) error
|
MarkProcessing(ctx context.Context, id string) (bool, error)
|
||||||
MarkProcessed(ctx context.Context, id string) error
|
MarkProcessed(ctx context.Context, id string) error
|
||||||
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
|
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
|
||||||
}
|
}
|
||||||
@@ -76,16 +77,88 @@ func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id stri
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *worksmobileOutboxRepository) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
|
||||||
|
result := r.db.WithContext(ctx).
|
||||||
|
Where("status = ? AND payload ->> 'tenantRootId' = ?", domain.WorksmobileOutboxStatusPending, tenantRootID).
|
||||||
|
Delete(&domain.WorksmobileOutbox{})
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
var rows []domain.WorksmobileOutbox
|
var rows []domain.WorksmobileOutbox
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.WorksmobileOutboxStatusPending, time.Now()).
|
WITH RECURSIVE candidates AS (
|
||||||
Order("created_at asc").
|
SELECT
|
||||||
Limit(limit).
|
*,
|
||||||
Find(&rows).Error
|
NULLIF(payload #>> '{request,orgUnitExternalKey}', '') AS org_external_key,
|
||||||
|
CASE
|
||||||
|
WHEN payload #>> '{request,parentOrgUnitId}' LIKE 'externalKey:%'
|
||||||
|
THEN NULLIF(substr(payload #>> '{request,parentOrgUnitId}', length('externalKey:') + 1), '')
|
||||||
|
ELSE ''
|
||||||
|
END AS parent_external_key
|
||||||
|
FROM worksmobile_outboxes
|
||||||
|
WHERE status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
||||||
|
),
|
||||||
|
ready AS (
|
||||||
|
SELECT candidates.*
|
||||||
|
FROM candidates
|
||||||
|
WHERE NOT (
|
||||||
|
candidates.resource_type = ?
|
||||||
|
AND candidates.action = ?
|
||||||
|
AND candidates.parent_external_key <> ''
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM worksmobile_outboxes parent_job
|
||||||
|
WHERE parent_job.resource_type = ?
|
||||||
|
AND parent_job.action = ?
|
||||||
|
AND parent_job.status <> ?
|
||||||
|
AND NULLIF(parent_job.payload #>> '{request,orgUnitExternalKey}', '') = candidates.parent_external_key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
org_depth AS (
|
||||||
|
SELECT id, org_external_key, parent_external_key, 0 AS depth
|
||||||
|
FROM ready
|
||||||
|
UNION ALL
|
||||||
|
SELECT child.id, child.org_external_key, child.parent_external_key, parent.depth + 1
|
||||||
|
FROM ready child
|
||||||
|
JOIN org_depth parent ON child.parent_external_key = parent.org_external_key
|
||||||
|
WHERE child.resource_type = ? AND child.action = ? AND parent.depth < 64
|
||||||
|
)
|
||||||
|
SELECT ready.*
|
||||||
|
FROM ready
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT max(depth) AS dependency_depth
|
||||||
|
FROM org_depth
|
||||||
|
WHERE org_depth.id = ready.id
|
||||||
|
) AS depth_rank ON true
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN ready.resource_type = ? AND ready.action = ? THEN 0
|
||||||
|
WHEN ready.resource_type = ? THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END ASC,
|
||||||
|
COALESCE(depth_rank.dependency_depth, 0) ASC,
|
||||||
|
ready.created_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
domain.WorksmobileOutboxStatusPending,
|
||||||
|
time.Now(),
|
||||||
|
domain.WorksmobileResourceOrgUnit,
|
||||||
|
domain.WorksmobileActionUpsert,
|
||||||
|
domain.WorksmobileResourceOrgUnit,
|
||||||
|
domain.WorksmobileActionUpsert,
|
||||||
|
domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
domain.WorksmobileResourceOrgUnit,
|
||||||
|
domain.WorksmobileActionUpsert,
|
||||||
|
domain.WorksmobileResourceOrgUnit,
|
||||||
|
domain.WorksmobileActionUpsert,
|
||||||
|
domain.WorksmobileResourceUser,
|
||||||
|
limit,
|
||||||
|
).Scan(&rows).Error
|
||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +179,12 @@ func (r *worksmobileOutboxRepository) MarkRetry(ctx context.Context, id string)
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) error {
|
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) (bool, error) {
|
||||||
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
|
result := r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
|
||||||
"status": domain.WorksmobileOutboxStatusProcessing,
|
"status": domain.WorksmobileOutboxStatusProcessing,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
}).Error
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *worksmobileOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
func (r *worksmobileOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
|
||||||
|
repo := NewWorksmobileOutboxRepository(testDB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
|
||||||
|
|
||||||
|
rows := []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000101",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
ResourceID: "user-pending",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
DedupeKey: "pending-root",
|
||||||
|
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000102",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
ResourceID: "user-other-root",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
DedupeKey: "pending-other-root",
|
||||||
|
Payload: domain.JSONMap{"tenantRootId": "root-2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000103",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
ResourceID: "user-failed",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusFailed,
|
||||||
|
DedupeKey: "failed-root",
|
||||||
|
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000104",
|
||||||
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||||
|
ResourceID: "org-processed",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
DedupeKey: "processed-root",
|
||||||
|
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
require.NoError(t, repo.Create(ctx, &rows[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := repo.DeletePendingByTenantRoot(ctx, "root-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), deleted)
|
||||||
|
var remaining []domain.WorksmobileOutbox
|
||||||
|
require.NoError(t, testDB.Order("id asc").Find(&remaining).Error)
|
||||||
|
require.Len(t, remaining, 3)
|
||||||
|
require.Equal(t, "00000000-0000-0000-0000-000000000102", remaining[0].ID)
|
||||||
|
require.Equal(t, "00000000-0000-0000-0000-000000000103", remaining[1].ID)
|
||||||
|
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
|
||||||
|
repo := NewWorksmobileOutboxRepository(testDB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
|
||||||
|
|
||||||
|
baseTime := time.Date(2026, 6, 2, 15, 21, 0, 0, time.UTC)
|
||||||
|
child := domain.WorksmobileOutbox{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000201",
|
||||||
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||||
|
ResourceID: "child-tenant",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
DedupeKey: "orgunit:upsert:child-tenant",
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"request": map[string]any{
|
||||||
|
"orgUnitExternalKey": "child-tenant",
|
||||||
|
"parentOrgUnitId": "externalKey:parent-tenant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: baseTime,
|
||||||
|
UpdatedAt: baseTime,
|
||||||
|
}
|
||||||
|
parent := domain.WorksmobileOutbox{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000202",
|
||||||
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||||
|
ResourceID: "parent-tenant",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
DedupeKey: "orgunit:upsert:parent-tenant",
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"request": map[string]any{
|
||||||
|
"orgUnitExternalKey": "parent-tenant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: baseTime.Add(time.Second),
|
||||||
|
UpdatedAt: baseTime.Add(time.Second),
|
||||||
|
}
|
||||||
|
require.NoError(t, testDB.Create(&child).Error)
|
||||||
|
require.NoError(t, testDB.Create(&parent).Error)
|
||||||
|
|
||||||
|
rows, err := repo.ListReady(ctx, 10)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.Equal(t, "parent-tenant", rows[0].ResourceID)
|
||||||
|
|
||||||
|
require.NoError(t, repo.MarkProcessed(ctx, parent.ID))
|
||||||
|
rows, err = repo.ListReady(ctx, 10)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.Equal(t, "child-tenant", rows[0].ResourceID)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type UserGroupService interface {
|
|||||||
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
|
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
|
||||||
Delete(ctx context.Context, tenantID, groupID string) error
|
Delete(ctx context.Context, tenantID, groupID string) error
|
||||||
Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error)
|
Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error)
|
||||||
|
SetWorksmobileSyncer(syncer WorksmobileSyncer)
|
||||||
|
|
||||||
// Member Management with Keto Sync
|
// Member Management with Keto Sync
|
||||||
AddMember(ctx context.Context, groupID, userID string) error
|
AddMember(ctx context.Context, groupID, userID string) error
|
||||||
@@ -35,6 +36,7 @@ type userGroupService struct {
|
|||||||
ketoService KetoService
|
ketoService KetoService
|
||||||
outboxRepo repository.KetoOutboxRepository
|
outboxRepo repository.KetoOutboxRepository
|
||||||
kratos KratosAdminService
|
kratos KratosAdminService
|
||||||
|
worksmobile WorksmobileSyncer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserGroupService(
|
func NewUserGroupService(
|
||||||
@@ -55,6 +57,10 @@ func NewUserGroupService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userGroupService) SetWorksmobileSyncer(syncer WorksmobileSyncer) {
|
||||||
|
s.worksmobile = syncer
|
||||||
|
}
|
||||||
|
|
||||||
func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
|
func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
|
||||||
// For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent.
|
// For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent.
|
||||||
actualParentID := parentID
|
actualParentID := parentID
|
||||||
@@ -261,6 +267,10 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
|||||||
localUser.Department = group.Name
|
localUser.Department = group.Name
|
||||||
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
||||||
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
||||||
|
} else if s.worksmobile != nil {
|
||||||
|
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
|
||||||
|
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,27 @@ func (m *MockUserRepository) DB() *gorm.DB {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeUserGroupWorksmobileSyncer struct {
|
||||||
|
userUpserts []domain.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
|
||||||
|
f.userUpserts = append(f.userUpserts, user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type MockKetoOutboxRepository struct {
|
type MockKetoOutboxRepository struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
@@ -337,6 +358,57 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
|||||||
mockKratos.AssertExpectations(t)
|
mockKratos.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
mockUserGroupRepo := new(MockUserGroupRepository)
|
||||||
|
mockUserRepo := new(MockUserRepository)
|
||||||
|
mockTenantRepo := new(MockTenantRepository)
|
||||||
|
mockKratos := new(MockKratosAdminServiceShared)
|
||||||
|
worksmobile := &fakeUserGroupWorksmobileSyncer{}
|
||||||
|
svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos)
|
||||||
|
svc.SetWorksmobileSyncer(worksmobile)
|
||||||
|
|
||||||
|
groupID := "group-1"
|
||||||
|
userID := "user-1"
|
||||||
|
tenantID := "tenant-1"
|
||||||
|
|
||||||
|
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
|
||||||
|
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{
|
||||||
|
ID: userID,
|
||||||
|
Email: "user@test.com",
|
||||||
|
Name: "User Test",
|
||||||
|
Status: "active",
|
||||||
|
}, nil)
|
||||||
|
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil)
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]any{"email": "user@test.com"},
|
||||||
|
State: "active",
|
||||||
|
}, nil)
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"},
|
||||||
|
State: "active",
|
||||||
|
}, nil)
|
||||||
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||||
|
})).Return(nil).Once()
|
||||||
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||||
|
})).Return(nil).Once()
|
||||||
|
|
||||||
|
err := svc.AddMember(context.Background(), groupID, userID)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, worksmobile.userUpserts, 1)
|
||||||
|
assert.Equal(t, userID, worksmobile.userUpserts[0].ID)
|
||||||
|
assert.NotNil(t, worksmobile.userUpserts[0].TenantID)
|
||||||
|
assert.Equal(t, tenantID, *worksmobile.userUpserts[0].TenantID)
|
||||||
|
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
||||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
mockUserGroupRepo := new(MockUserGroupRepository)
|
mockUserGroupRepo := new(MockUserGroupRepository)
|
||||||
|
|||||||
@@ -658,6 +658,84 @@ func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testin
|
|||||||
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
|
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileRelayWorkerProcessesOrgUnitParentsBeforeChildren(t *testing.T) {
|
||||||
|
repo := &fakeWorksmobileOutboxRepo{
|
||||||
|
ready: []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "job-child",
|
||||||
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||||
|
ResourceID: "child-tenant",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"request": map[string]any{
|
||||||
|
"domainId": 300293726,
|
||||||
|
"orgUnitExternalKey": "child-tenant",
|
||||||
|
"orgUnitName": "child",
|
||||||
|
"parentOrgUnitId": "externalKey:parent-tenant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "job-parent",
|
||||||
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||||
|
ResourceID: "parent-tenant",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"request": map[string]any{
|
||||||
|
"domainId": 300293726,
|
||||||
|
"orgUnitExternalKey": "parent-tenant",
|
||||||
|
"orgUnitName": "parent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &fakeWorksmobileDirectoryClient{}
|
||||||
|
worker := NewWorksmobileRelayWorker(repo, client)
|
||||||
|
|
||||||
|
err := worker.ProcessOnce(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"job-parent", "job-child"}, repo.processingIDs)
|
||||||
|
require.Equal(t, []string{"parent-tenant", "child-tenant"}, []string{
|
||||||
|
client.createdOrgUnits[0].OrgUnitExternalKey,
|
||||||
|
client.createdOrgUnits[1].OrgUnitExternalKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileRelayWorkerSkipsDispatchWhenJobClaimFails(t *testing.T) {
|
||||||
|
repo := &fakeWorksmobileOutboxRepo{
|
||||||
|
markProcessingClaims: map[string]bool{"job-claimed-by-other-worker": false},
|
||||||
|
ready: []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "job-claimed-by-other-worker",
|
||||||
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||||
|
ResourceID: "org-1",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"request": map[string]any{
|
||||||
|
"domainId": 300293726,
|
||||||
|
"orgUnitExternalKey": "org-1",
|
||||||
|
"orgUnitName": "org",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &fakeWorksmobileDirectoryClient{}
|
||||||
|
worker := NewWorksmobileRelayWorker(repo, client)
|
||||||
|
|
||||||
|
err := worker.ProcessOnce(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, repo.processingIDs)
|
||||||
|
require.Empty(t, repo.processedIDs)
|
||||||
|
require.Empty(t, client.createdOrgUnits)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
|
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
|
||||||
jobs := []domain.WorksmobileOutbox{
|
jobs := []domain.WorksmobileOutbox{
|
||||||
{
|
{
|
||||||
@@ -1094,14 +1172,17 @@ func boolPtr(value bool) *bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeWorksmobileOutboxRepo struct {
|
type fakeWorksmobileOutboxRepo struct {
|
||||||
recent []domain.WorksmobileOutbox
|
recent []domain.WorksmobileOutbox
|
||||||
ready []domain.WorksmobileOutbox
|
ready []domain.WorksmobileOutbox
|
||||||
created []domain.WorksmobileOutbox
|
created []domain.WorksmobileOutbox
|
||||||
credentialBatchJobs []domain.WorksmobileOutbox
|
credentialBatchJobs []domain.WorksmobileOutbox
|
||||||
payloadUpdates []domain.JSONMap
|
payloadUpdates []domain.JSONMap
|
||||||
processingIDs []string
|
deletedPendingTenantRootID string
|
||||||
processedIDs []string
|
deletedPendingCount int
|
||||||
failedIDs []string
|
markProcessingClaims map[string]bool
|
||||||
|
processingIDs []string
|
||||||
|
processedIDs []string
|
||||||
|
failedIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
||||||
@@ -1137,6 +1218,11 @@ func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileOutboxRepo) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
|
||||||
|
f.deletedPendingTenantRootID = tenantRootID
|
||||||
|
return int64(f.deletedPendingCount), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||||
return f.ready, nil
|
return f.ready, nil
|
||||||
}
|
}
|
||||||
@@ -1149,9 +1235,12 @@ func (f *fakeWorksmobileOutboxRepo) MarkRetry(ctx context.Context, id string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
|
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) (bool, error) {
|
||||||
|
if f.markProcessingClaims != nil && !f.markProcessingClaims[id] {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
f.processingIDs = append(f.processingIDs, id)
|
f.processingIDs = append(f.processingIDs, id)
|
||||||
return nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
|
func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tena
|
|||||||
|
|
||||||
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
|
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
|
||||||
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
|
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
|
||||||
|
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv("WORKS_DEFAULT_DOMAIN_" + envKey))); domainName != "" {
|
||||||
|
return domainName
|
||||||
|
}
|
||||||
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
|
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
|
||||||
return domainName
|
return domainName
|
||||||
}
|
}
|
||||||
@@ -136,6 +139,8 @@ func worksmobileTenantMailDomain(tenant domain.Tenant) string {
|
|||||||
return "hanmaceng.co.kr"
|
return "hanmaceng.co.kr"
|
||||||
case "GPDTDC":
|
case "GPDTDC":
|
||||||
return "baroncs.co.kr"
|
return "baroncs.co.kr"
|
||||||
|
case "HALLA":
|
||||||
|
return "hallasanup.com"
|
||||||
case "BARONGROUP":
|
case "BARONGROUP":
|
||||||
return "brsw.kr"
|
return "brsw.kr"
|
||||||
default:
|
default:
|
||||||
@@ -493,6 +498,10 @@ func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant dom
|
|||||||
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
|
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
|
||||||
return domainID, nil
|
return domainID, nil
|
||||||
}
|
}
|
||||||
|
case "hallasanup.com":
|
||||||
|
if domainID, ok := worksmobileDomainIDFromEnv("HALLA_DOMAIN_ID"); ok {
|
||||||
|
return domainID, nil
|
||||||
|
}
|
||||||
case "brsw.kr":
|
case "brsw.kr":
|
||||||
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
|
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
|
||||||
return domainID, nil
|
return domainID, nil
|
||||||
@@ -524,6 +533,8 @@ func worksmobileDomainIDEnvKeyFromEmail(email string) string {
|
|||||||
return "HANMAC_DOMAIN_ID"
|
return "HANMAC_DOMAIN_ID"
|
||||||
case "baroncs.co.kr":
|
case "baroncs.co.kr":
|
||||||
return "GPDTDC_DOMAIN_ID"
|
return "GPDTDC_DOMAIN_ID"
|
||||||
|
case "hallasanup.com":
|
||||||
|
return "HALLA_DOMAIN_ID"
|
||||||
case "brsw.kr":
|
case "brsw.kr":
|
||||||
return "BARONGROUP_DOMAIN_ID"
|
return "BARONGROUP_DOMAIN_ID"
|
||||||
default:
|
default:
|
||||||
@@ -574,6 +585,9 @@ func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
|
|||||||
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
|
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
|
||||||
return "GPDTDC_DOMAIN_ID"
|
return "GPDTDC_DOMAIN_ID"
|
||||||
}
|
}
|
||||||
|
if isHallaWorksmobileTenant(tenant) {
|
||||||
|
return "HALLA_DOMAIN_ID"
|
||||||
|
}
|
||||||
return "BARONGROUP_DOMAIN_ID"
|
return "BARONGROUP_DOMAIN_ID"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +609,7 @@ func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping {
|
|||||||
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
|
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
|
||||||
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
|
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
|
||||||
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
|
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
|
||||||
|
{Key: "HALLA_DOMAIN_ID", Label: "한라산업개발"},
|
||||||
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
|
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,6 +640,10 @@ func isHanmacWorksmobileTenant(tenant domain.Tenant) bool {
|
|||||||
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
|
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isHallaWorksmobileTenant(tenant domain.Tenant) bool {
|
||||||
|
return tenantHasDomain(tenant, "hallasanup.com") || tenantMatchesAny(tenant, "halla", "hanlla", "한라산업개발")
|
||||||
|
}
|
||||||
|
|
||||||
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
|
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
|
||||||
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
||||||
for _, d := range tenant.Domains {
|
for _, d := range tenant.Domains {
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
|
|||||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -468,6 +469,16 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
|
|||||||
tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"},
|
tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"},
|
||||||
want: 1003,
|
want: 1003,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "halla",
|
||||||
|
tenant: domain.Tenant{Slug: "halla", Name: "한라산업개발", Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}}},
|
||||||
|
want: 1005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hanlla legacy slug",
|
||||||
|
tenant: domain.Tenant{Slug: "hanlla", Name: "한라산업개발"},
|
||||||
|
want: 1005,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "barongroup fallback",
|
name: "barongroup fallback",
|
||||||
tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"},
|
tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"},
|
||||||
@@ -484,6 +495,58 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveWorksmobileAccountDomainIDUsesHallaEmailDomain(t *testing.T) {
|
||||||
|
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||||
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
Slug: "halla",
|
||||||
|
Name: "한라산업개발",
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ResolveWorksmobileAccountDomainIDFromEmail("user@hallasanup.com", tenant, nil)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1005), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileDomainIDsFromEnvIncludesHallaBeforeFallback(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||||
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||||
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
|
||||||
|
got := WorksmobileDomainIDsFromEnv()
|
||||||
|
|
||||||
|
require.Equal(t, []int64{1001, 1002, 1003, 1005, 1004}, got)
|
||||||
|
require.Equal(t, "한라산업개발", WorksmobileDomainLabelForID(1005))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWorksmobileUserPayloadUsesHallaDomain(t *testing.T) {
|
||||||
|
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||||
|
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
|
||||||
|
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||||
|
user := domain.User{
|
||||||
|
ID: "44444444-4444-4444-4444-444444444444",
|
||||||
|
Email: "main@hallasanup.com",
|
||||||
|
Name: "Halla User",
|
||||||
|
TenantID: &tenantID,
|
||||||
|
}
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Slug: "halla",
|
||||||
|
Name: "한라산업개발",
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1005), payload.DomainID)
|
||||||
|
require.Equal(t, "main@hallasanup.com", payload.Email)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) {
|
func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) {
|
||||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,7 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
jobs = sortWorksmobileReadyJobs(jobs)
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
if err := w.processJob(ctx, job); err != nil {
|
if err := w.processJob(ctx, job); err != nil {
|
||||||
slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err)
|
slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err)
|
||||||
@@ -62,11 +64,15 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error {
|
func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error {
|
||||||
if err := w.repo.MarkProcessing(ctx, job.ID); err != nil {
|
claimed, err := w.repo.MarkProcessing(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !claimed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
err := w.dispatch(ctx, job)
|
err = w.dispatch(ctx, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount))
|
nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount))
|
||||||
_ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt)
|
_ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt)
|
||||||
@@ -136,6 +142,91 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortWorksmobileReadyJobs(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
|
||||||
|
sorted := append([]domain.WorksmobileOutbox(nil), jobs...)
|
||||||
|
depthByID := worksmobileOrgUnitDepths(sorted)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
leftClass := worksmobileRelayOrderClass(sorted[i])
|
||||||
|
rightClass := worksmobileRelayOrderClass(sorted[j])
|
||||||
|
if leftClass != rightClass {
|
||||||
|
return leftClass < rightClass
|
||||||
|
}
|
||||||
|
leftDepth := depthByID[sorted[i].ID]
|
||||||
|
rightDepth := depthByID[sorted[j].ID]
|
||||||
|
if leftDepth != rightDepth {
|
||||||
|
return leftDepth < rightDepth
|
||||||
|
}
|
||||||
|
return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileRelayOrderClass(job domain.WorksmobileOutbox) int {
|
||||||
|
if job.ResourceType == domain.WorksmobileResourceOrgUnit && job.Action == domain.WorksmobileActionUpsert {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if job.ResourceType == domain.WorksmobileResourceUser {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileOrgUnitDepths(jobs []domain.WorksmobileOutbox) map[string]int {
|
||||||
|
type orgUnitJob struct {
|
||||||
|
jobID string
|
||||||
|
parentKey string
|
||||||
|
}
|
||||||
|
byExternalKey := map[string]orgUnitJob{}
|
||||||
|
for _, job := range jobs {
|
||||||
|
externalKey, parentKey := worksmobileOrgUnitExternalKeys(job)
|
||||||
|
if externalKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byExternalKey[externalKey] = orgUnitJob{jobID: job.ID, parentKey: parentKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
depthByExternalKey := map[string]int{}
|
||||||
|
var depth func(externalKey string, seen map[string]bool) int
|
||||||
|
depth = func(externalKey string, seen map[string]bool) int {
|
||||||
|
if value, ok := depthByExternalKey[externalKey]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
job, ok := byExternalKey[externalKey]
|
||||||
|
if !ok || job.parentKey == "" || seen[externalKey] {
|
||||||
|
depthByExternalKey[externalKey] = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
seen[externalKey] = true
|
||||||
|
value := depth(job.parentKey, seen) + 1
|
||||||
|
delete(seen, externalKey)
|
||||||
|
depthByExternalKey[externalKey] = value
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
depthByJobID := map[string]int{}
|
||||||
|
for externalKey, job := range byExternalKey {
|
||||||
|
depthByJobID[job.jobID] = depth(externalKey, map[string]bool{})
|
||||||
|
}
|
||||||
|
return depthByJobID
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileOrgUnitExternalKeys(job domain.WorksmobileOutbox) (string, string) {
|
||||||
|
if job.ResourceType != domain.WorksmobileResourceOrgUnit || job.Action != domain.WorksmobileActionUpsert {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
var payload WorksmobileOrgUnitPayload
|
||||||
|
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parentKey := strings.TrimSpace(payload.ParentOrgUnitID)
|
||||||
|
if strings.HasPrefix(parentKey, "externalKey:") {
|
||||||
|
parentKey = strings.TrimSpace(strings.TrimPrefix(parentKey, "externalKey:"))
|
||||||
|
} else {
|
||||||
|
parentKey = ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(payload.OrgUnitExternalKey), parentKey
|
||||||
|
}
|
||||||
|
|
||||||
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
|
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
|
||||||
userID := stringValue(job.Payload["loginEmail"])
|
userID := stringValue(job.Payload["loginEmail"])
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const HanmacFamilyTenantSlug = "hanmac-family"
|
const HanmacFamilyTenantSlug = "hanmac-family"
|
||||||
|
const worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||||
|
|
||||||
type WorksmobileSyncer interface {
|
type WorksmobileSyncer interface {
|
||||||
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||||
@@ -31,6 +32,7 @@ type WorksmobileAdminService interface {
|
|||||||
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||||
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||||
|
DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error)
|
||||||
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
|
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||||
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
|
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
|
||||||
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
|
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
|
||||||
@@ -54,6 +56,10 @@ type WorksmobileBackfillDryRun struct {
|
|||||||
UserCount int `json:"userCount"`
|
UserCount int `json:"userCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorksmobilePendingJobDeleteResult struct {
|
||||||
|
DeletedCount int `json:"deletedCount"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorksmobileInitialPasswordCredential struct {
|
type WorksmobileInitialPasswordCredential struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
@@ -178,6 +184,21 @@ func worksmobileDirectoryAuthConfigured() bool {
|
|||||||
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
|
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WorksmobileExcluded(config domain.JSONMap) bool {
|
||||||
|
rawValue, ok := config[worksmobileExcludedConfigKey]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch value := rawValue.(type) {
|
||||||
|
case bool:
|
||||||
|
return value
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(value), "true")
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
|
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
|
||||||
for i := range jobs {
|
for i := range jobs {
|
||||||
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
|
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
|
||||||
@@ -394,6 +415,9 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||||
|
return nil, errors.New("target tenant is excluded from Worksmobile sync")
|
||||||
|
}
|
||||||
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
|
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
|
||||||
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
|
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
|
||||||
}
|
}
|
||||||
@@ -511,13 +535,16 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||||
|
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||||
|
}
|
||||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||||
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
||||||
}
|
}
|
||||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||||
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
|
||||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||||
*user,
|
*user,
|
||||||
*tenant,
|
*tenant,
|
||||||
@@ -582,6 +609,9 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||||
|
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||||
|
}
|
||||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -722,6 +752,18 @@ func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID s
|
|||||||
return s.outboxRepo.FindByID(ctx, jobID)
|
return s.outboxRepo.FindByID(ctx, jobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *worksmobileSyncService) DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error) {
|
||||||
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return WorksmobilePendingJobDeleteResult{}, err
|
||||||
|
}
|
||||||
|
deleted, err := s.outboxRepo.DeletePendingByTenantRoot(ctx, root.ID)
|
||||||
|
if err != nil {
|
||||||
|
return WorksmobilePendingJobDeleteResult{}, err
|
||||||
|
}
|
||||||
|
return WorksmobilePendingJobDeleteResult{DeletedCount: int(deleted)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||||
root, ok, err := s.rootForTenant(ctx, tenant)
|
root, ok, err := s.rootForTenant(ctx, tenant)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
@@ -732,6 +774,9 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -767,6 +812,9 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -795,6 +843,10 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[*user.TenantID]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||||
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
|
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
|
||||||
return err
|
return err
|
||||||
@@ -802,7 +854,6 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
|||||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
|
||||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||||
user,
|
user,
|
||||||
*tenant,
|
*tenant,
|
||||||
@@ -833,10 +884,18 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, ok, err := s.rootForTenant(ctx, *tenant)
|
root, ok, err := s.rootForTenant(ctx, *tenant)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
if _, ok := tenantByID[*user.TenantID]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
|
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -891,6 +950,9 @@ func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID strin
|
|||||||
var visit func(id string)
|
var visit func(id string)
|
||||||
visit = func(id string) {
|
visit = func(id string) {
|
||||||
for _, child := range byParent[id] {
|
for _, child := range byParent[id] {
|
||||||
|
if WorksmobileExcluded(child.Config) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
result = append(result, child)
|
result = append(result, child)
|
||||||
visit(child.ID)
|
visit(child.ID)
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1073,9 @@ func normalizeWorksmobileSlugLocalPart(value string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
||||||
|
if isWorksmobileDomainRootTenant(tenant) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if tenant.Type == domain.TenantTypeOrganization {
|
if tenant.Type == domain.TenantTypeOrganization {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1048,12 +1113,13 @@ func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[
|
|||||||
func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
||||||
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||||
switch slug {
|
switch slug {
|
||||||
case "saman", "hanmac", "gpdtdc", "baron-group":
|
case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if tenantHasDomain(tenant, "samaneng.com") ||
|
if tenantHasDomain(tenant, "samaneng.com") ||
|
||||||
tenantHasDomain(tenant, "hanmaceng.co.kr") ||
|
tenantHasDomain(tenant, "hanmaceng.co.kr") ||
|
||||||
tenantHasDomain(tenant, "baroncs.co.kr") ||
|
tenantHasDomain(tenant, "baroncs.co.kr") ||
|
||||||
|
tenantHasDomain(tenant, "hallasanup.com") ||
|
||||||
tenantHasDomain(tenant, "brsw.kr") {
|
tenantHasDomain(tenant, "brsw.kr") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1061,6 +1127,7 @@ func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
|||||||
return name == "삼안" ||
|
return name == "삼안" ||
|
||||||
name == "한맥기술" ||
|
name == "한맥기술" ||
|
||||||
name == "총괄기획&기술개발센터" ||
|
name == "총괄기획&기술개발센터" ||
|
||||||
|
name == "한라산업개발" ||
|
||||||
name == "바론그룹"
|
name == "바론그룹"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -494,6 +494,70 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
|
|||||||
}, orgPayload["requestSummary"])
|
}, orgPayload["requestSummary"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
rootID := "root-tenant"
|
||||||
|
companyID := "saman-tenant"
|
||||||
|
newParentID := "new-parent-org"
|
||||||
|
childID := "child-org"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
Type: domain.TenantTypeCompanyGroup,
|
||||||
|
}
|
||||||
|
company := domain.Tenant{
|
||||||
|
ID: companyID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "삼안",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||||
|
}
|
||||||
|
newParent := domain.Tenant{
|
||||||
|
ID: newParentID,
|
||||||
|
Slug: "planning",
|
||||||
|
Name: "총괄기획",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &companyID,
|
||||||
|
}
|
||||||
|
child := domain.Tenant{
|
||||||
|
ID: childID,
|
||||||
|
Slug: "people-growth",
|
||||||
|
Name: "인재성장",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &newParentID,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{
|
||||||
|
tenants: map[string]domain.Tenant{
|
||||||
|
rootID: root,
|
||||||
|
companyID: company,
|
||||||
|
newParentID: newParent,
|
||||||
|
childID: child,
|
||||||
|
},
|
||||||
|
list: []domain.Tenant{root, company, newParent, child},
|
||||||
|
},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := service.EnqueueTenantUpsertIfInScope(context.Background(), child)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, outboxRepo.created, 1)
|
||||||
|
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
|
||||||
|
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||||
|
require.Equal(t, childID, outboxRepo.created[0].ResourceID)
|
||||||
|
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, childID, request.OrgUnitExternalKey)
|
||||||
|
require.Equal(t, "externalKey:"+newParentID, request.ParentOrgUnitID)
|
||||||
|
require.Equal(t, "people-growth", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
|
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
|
||||||
parentID := "root-tenant"
|
parentID := "root-tenant"
|
||||||
root := domain.Tenant{
|
root := domain.Tenant{
|
||||||
@@ -1085,10 +1149,34 @@ func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testin
|
|||||||
require.Empty(t, outboxRepo.created)
|
require.Empty(t, outboxRepo.created)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceDeletesPendingJobsForTenantRoot(t *testing.T) {
|
||||||
|
rootID := "root-tenant"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{deletedPendingCount: 2}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := service.DeletePendingJobs(context.Background(), rootID)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, result.DeletedCount)
|
||||||
|
require.Equal(t, rootID, outboxRepo.deletedPendingTenantRootID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
|
func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
|
||||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||||
|
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
|
||||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
root := domain.Tenant{
|
root := domain.Tenant{
|
||||||
@@ -1177,6 +1265,43 @@ func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *
|
|||||||
wantDomainID: 1004,
|
wantDomainID: 1004,
|
||||||
wantEmail: "baron-planning@brsw.kr",
|
wantEmail: "baron-planning@brsw.kr",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "halla",
|
||||||
|
company: domain.Tenant{
|
||||||
|
ID: "company-halla",
|
||||||
|
Slug: "halla",
|
||||||
|
Name: "한라산업개발",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
|
||||||
|
},
|
||||||
|
organization: domain.Tenant{
|
||||||
|
ID: "org-halla-planning",
|
||||||
|
Slug: "halla-planning",
|
||||||
|
Name: "한라 기획팀",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
},
|
||||||
|
wantDomainID: 1005,
|
||||||
|
wantEmail: "halla-planning@hallasanup.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hanlla legacy slug",
|
||||||
|
company: domain.Tenant{
|
||||||
|
ID: "company-hanlla",
|
||||||
|
Slug: "hanlla",
|
||||||
|
Name: "한라산업개발",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &rootID,
|
||||||
|
},
|
||||||
|
organization: domain.Tenant{
|
||||||
|
ID: "org-hanlla-construction-sites",
|
||||||
|
Slug: "hanlla-construction-sites",
|
||||||
|
Name: "시공현장",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
},
|
||||||
|
wantDomainID: 1005,
|
||||||
|
wantEmail: "hanlla-construction-sites@hallasanup.com",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1467,6 +1592,181 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
|||||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceBackfillDryRunSkipsWorksmobileExcludedSubtree(t *testing.T) {
|
||||||
|
rootID := "root-tenant"
|
||||||
|
excludedCompanyID := "excluded-company"
|
||||||
|
excludedOrgID := "excluded-org"
|
||||||
|
includedCompanyID := "included-company"
|
||||||
|
includedOrgID := "included-org"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
}
|
||||||
|
excludedCompany := domain.Tenant{
|
||||||
|
ID: excludedCompanyID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "삼안",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||||
|
}
|
||||||
|
excludedOrg := domain.Tenant{
|
||||||
|
ID: excludedOrgID,
|
||||||
|
Slug: "excluded-team",
|
||||||
|
Name: "제외팀",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &excludedCompanyID,
|
||||||
|
}
|
||||||
|
includedCompany := domain.Tenant{
|
||||||
|
ID: includedCompanyID,
|
||||||
|
Slug: "halla",
|
||||||
|
Name: "한라산업개발",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
}
|
||||||
|
includedOrg := domain.Tenant{
|
||||||
|
ID: includedOrgID,
|
||||||
|
Slug: "included-team",
|
||||||
|
Name: "연동팀",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &includedCompanyID,
|
||||||
|
}
|
||||||
|
excludedUser := domain.User{
|
||||||
|
ID: "excluded-user",
|
||||||
|
Email: "excluded@samaneng.com",
|
||||||
|
Name: "Excluded User",
|
||||||
|
TenantID: &excludedOrgID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
}
|
||||||
|
includedUser := domain.User{
|
||||||
|
ID: "included-user",
|
||||||
|
Email: "included@hallasanup.com",
|
||||||
|
Name: "Included User",
|
||||||
|
TenantID: &includedOrgID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{
|
||||||
|
tenants: map[string]domain.Tenant{
|
||||||
|
rootID: root,
|
||||||
|
excludedCompanyID: excludedCompany,
|
||||||
|
excludedOrgID: excludedOrg,
|
||||||
|
includedCompanyID: includedCompany,
|
||||||
|
includedOrgID: includedOrg,
|
||||||
|
},
|
||||||
|
list: []domain.Tenant{root, excludedCompany, excludedOrg, includedCompany, includedOrg},
|
||||||
|
},
|
||||||
|
&fakeWorksmobileUserRepo{byTenant: []domain.User{excludedUser, includedUser}},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, dryRun.OrgUnitCount)
|
||||||
|
require.Equal(t, 1, dryRun.UserCount)
|
||||||
|
require.Len(t, outboxRepo.created, 1)
|
||||||
|
require.ElementsMatch(t, []string{includedOrgID}, outboxRepo.created[0].Payload["tenantIds"])
|
||||||
|
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
|
||||||
|
rootID := "root-tenant"
|
||||||
|
excludedCompanyID := "excluded-company"
|
||||||
|
excludedOrgID := "excluded-org"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
}
|
||||||
|
excludedCompany := domain.Tenant{
|
||||||
|
ID: excludedCompanyID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "삼안",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||||
|
}
|
||||||
|
excludedOrg := domain.Tenant{
|
||||||
|
ID: excludedOrgID,
|
||||||
|
Slug: "excluded-team",
|
||||||
|
Name: "제외팀",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &excludedCompanyID,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{
|
||||||
|
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
|
||||||
|
list: []domain.Tenant{root, excludedCompany, excludedOrg},
|
||||||
|
},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, excludedOrgID)
|
||||||
|
|
||||||
|
require.Nil(t, item)
|
||||||
|
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||||
|
require.Empty(t, outboxRepo.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
||||||
|
rootID := "root-tenant"
|
||||||
|
excludedCompanyID := "excluded-company"
|
||||||
|
excludedOrgID := "excluded-org"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
}
|
||||||
|
excludedCompany := domain.Tenant{
|
||||||
|
ID: excludedCompanyID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "삼안",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||||
|
}
|
||||||
|
excludedOrg := domain.Tenant{
|
||||||
|
ID: excludedOrgID,
|
||||||
|
Slug: "excluded-team",
|
||||||
|
Name: "제외팀",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &excludedCompanyID,
|
||||||
|
}
|
||||||
|
user := domain.User{
|
||||||
|
ID: "excluded-user",
|
||||||
|
Email: "excluded@samaneng.com",
|
||||||
|
Name: "Excluded User",
|
||||||
|
TenantID: &excludedOrgID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{
|
||||||
|
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
|
||||||
|
list: []domain.Tenant{root, excludedCompany, excludedOrg},
|
||||||
|
},
|
||||||
|
&fakeWorksmobileUserRepo{byID: map[string]domain.User{user.ID: user}},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, service.EnqueueTenantUpsertIfInScope(context.Background(), excludedOrg))
|
||||||
|
require.NoError(t, service.EnqueueTenantDeleteIfInScope(context.Background(), excludedOrg))
|
||||||
|
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
||||||
|
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "")
|
||||||
|
|
||||||
|
require.Nil(t, item)
|
||||||
|
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||||
|
require.Empty(t, outboxRepo.created)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||||
tenantID := "tenant-leaf"
|
tenantID := "tenant-leaf"
|
||||||
user := domain.User{
|
user := domain.User{
|
||||||
@@ -1751,7 +2051,23 @@ func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes
|
|||||||
|
|
||||||
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||||
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
|
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
|
||||||
return f.byTenant, nil
|
if len(tenantIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
allowed := make(map[string]bool, len(tenantIDs))
|
||||||
|
for _, tenantID := range tenantIDs {
|
||||||
|
allowed[tenantID] = true
|
||||||
|
}
|
||||||
|
users := make([]domain.User, 0, len(f.byTenant))
|
||||||
|
for _, user := range f.byTenant {
|
||||||
|
if user.TenantID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if allowed[*user.TenantID] {
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
||||||
|
|||||||
126
docs/worksmobile-halla-domain-migration-plan.md
Normal file
126
docs/worksmobile-halla-domain-migration-plan.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# 한라 WORKS 도메인 분리 및 조직 연동 계획
|
||||||
|
|
||||||
|
## 현황 확인
|
||||||
|
|
||||||
|
- 로컬 seed 기준 `halla`는 `hanmac-family` 직속 `COMPANY`입니다.
|
||||||
|
- 로컬 DB 기준 `halla`는 `hanmac-family` 직속이 맞지만, 타입은 현재 `ORGANIZATION`입니다.
|
||||||
|
- 한라 하위 테넌트는 로컬 DB 기준 전체 43개입니다.
|
||||||
|
- 한라 직속 하위 조직은 10개입니다.
|
||||||
|
- 경영지원본부
|
||||||
|
- 기반사업본부
|
||||||
|
- 기술영업본부
|
||||||
|
- 시공현장
|
||||||
|
- 안전관리본부
|
||||||
|
- 업무총괄
|
||||||
|
- 영업총괄
|
||||||
|
- 운영사업소
|
||||||
|
- 임원실
|
||||||
|
- 환경플랜트사업본부
|
||||||
|
|
||||||
|
## 현재 코드의 문제
|
||||||
|
|
||||||
|
현재 WORKS 도메인 분류는 다음 네 가지 도메인만 알고 있습니다.
|
||||||
|
|
||||||
|
- `SAMAN_DOMAIN_ID`
|
||||||
|
- `HANMAC_DOMAIN_ID`
|
||||||
|
- `GPDTDC_DOMAIN_ID`
|
||||||
|
- `BARONGROUP_DOMAIN_ID`
|
||||||
|
|
||||||
|
`HALLA_DOMAIN_ID`가 없기 때문에 `halla`와 `hallasanup.com`은 별도 도메인 루트로 판정되지 않습니다. 이 상태에서 조직 연동을 실행하면 한라 하위 조직이 HALLA 도메인이 아니라 fallback 도메인으로 분류될 수 있습니다.
|
||||||
|
|
||||||
|
또한 로컬 DB에서 `halla` 타입이 `ORGANIZATION`이면 “별도 회사 도메인 루트”라는 의미가 약합니다. seed와 실제 DB를 `COMPANY`로 맞추는 마이그레이션이 필요합니다.
|
||||||
|
|
||||||
|
## 목표 구조
|
||||||
|
|
||||||
|
Baron 내부 구조:
|
||||||
|
|
||||||
|
- `hanmac-family`
|
||||||
|
- `halla` (`COMPANY`, WORKS domain root, `HALLA_DOMAIN_ID`, `hallasanup.com`)
|
||||||
|
- 한라 하위 조직들 (`ORGANIZATION`)
|
||||||
|
|
||||||
|
한맥가족 직속 회사/그룹 배치 순서:
|
||||||
|
|
||||||
|
1. `gpdtdc` - 총괄기획&기술개발센터
|
||||||
|
2. `saman` - 삼안
|
||||||
|
3. `hanmac` - 한맥기술
|
||||||
|
4. `baron-group` - 바론그룹
|
||||||
|
5. `halla` - 한라산업개발
|
||||||
|
|
||||||
|
WORKS Mobile 구조:
|
||||||
|
|
||||||
|
- `HALLA_DOMAIN_ID`
|
||||||
|
- 한라 depth 1 조직은 HALLA 도메인의 최상위 org unit으로 생성합니다.
|
||||||
|
- 한라 depth 2 이상 조직은 Baron의 부모 조직 external key를 따라 하위 org unit으로 생성합니다.
|
||||||
|
- `halla` 회사 테넌트 자체는 WORKS org unit으로 만들지 않습니다. 도메인 루트 분류 기준으로만 사용합니다.
|
||||||
|
|
||||||
|
예상 매핑:
|
||||||
|
|
||||||
|
- `halla-mgmt-support-hq` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId=""`
|
||||||
|
- `halla-mgmt-support` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId="externalKey:d656c134-a50b-43b9-8c2d-fb3738dd0f9f"`
|
||||||
|
- `site-gyeongsan-road` -> `HALLA_DOMAIN_ID`, 실제 Baron parent external key 유지
|
||||||
|
|
||||||
|
## 구현 계획
|
||||||
|
|
||||||
|
1. 도메인 루트 판정 추가
|
||||||
|
- `isWorksmobileDomainRootTenant`에 `halla`, `hallasanup.com`, `한라산업개발`을 추가합니다.
|
||||||
|
- `worksmobileTenantDomainIDEnvKey`가 한라 테넌트를 `HALLA_DOMAIN_ID`로 반환하도록 추가합니다.
|
||||||
|
|
||||||
|
2. 이메일 도메인 판정 추가
|
||||||
|
- `ResolveWorksmobileAccountDomainIDFromEmail`에 `hallasanup.com -> HALLA_DOMAIN_ID`를 추가합니다.
|
||||||
|
- `worksmobileDomainIDEnvKeyFromEmail`에도 같은 매핑을 추가합니다.
|
||||||
|
- 사용자 주 이메일 또는 alias가 `hallasanup.com`이면 HALLA 계정/조직 이메일로 매핑되도록 검증합니다.
|
||||||
|
|
||||||
|
3. WORKS remote 조회 범위 추가
|
||||||
|
- `worksmobileDomainEnvMappings`에 `HALLA_DOMAIN_ID`와 label `한라산업개발`을 추가합니다.
|
||||||
|
- `WorksmobileDomainIDsFromEnv`가 HALLA 도메인도 remote user/group 조회 대상으로 포함해야 합니다.
|
||||||
|
|
||||||
|
4. 로컬 데이터 마이그레이션
|
||||||
|
- `halla`가 `hanmac-family` 직속인지 확인합니다.
|
||||||
|
- `halla` 타입을 `COMPANY`로 맞춥니다.
|
||||||
|
- `halla` 도메인을 `hallasanup.com`으로 유지합니다.
|
||||||
|
- 필요한 경우 `halla` 관련 기존 WORKS outbox pending 작업을 정리하거나 재등록합니다.
|
||||||
|
|
||||||
|
5. 조직 연동 순서
|
||||||
|
- 먼저 comparison dry-run으로 HALLA 도메인 예상값을 확인합니다.
|
||||||
|
- 한라 하위 조직만 대상으로 org unit upsert를 등록합니다.
|
||||||
|
- WORKS에서 같은 external key가 다른 도메인에 이미 붙어 있으면 기존 도메인 external key를 clear한 뒤 HALLA 도메인에 재등록합니다.
|
||||||
|
- 조직 연동 성공 후 사용자 연동을 진행합니다.
|
||||||
|
|
||||||
|
6. 사용자 연동 기준
|
||||||
|
- Baron representative/primary가 `halla` 또는 한라 하위 조직이면 `HALLA_DOMAIN_ID` 조직 membership을 생성합니다.
|
||||||
|
- 주 이메일이 `hallasanup.com`이면 HALLA domain account로 생성합니다.
|
||||||
|
- 다른 회사 주 이메일을 가진 겸직 사용자는 계정 domain은 주 이메일 기준으로 유지하고, HALLA 조직은 `organizations[]`의 추가 조직으로 매핑합니다.
|
||||||
|
|
||||||
|
## 테스트 계획
|
||||||
|
|
||||||
|
- `worksmobile_mapper_test.go`
|
||||||
|
- `halla`와 `hallasanup.com`이 `HALLA_DOMAIN_ID`로 resolve되는지 검증합니다.
|
||||||
|
- `hallasanup.com` 이메일이 HALLA account domain으로 resolve되는지 검증합니다.
|
||||||
|
- `WorksmobileDomainIDsFromEnv`에 HALLA 도메인이 포함되는지 검증합니다.
|
||||||
|
|
||||||
|
- `worksmobile_sync_service_test.go`
|
||||||
|
- 한맥가족 직속 회사 `halla`를 domain root로 판정하는 테스트를 추가합니다.
|
||||||
|
- 한라 depth 1 조직은 `parentOrgUnitId=""`로 생성되는지 검증합니다.
|
||||||
|
- 한라 depth 2 조직은 `parentOrgUnitId="externalKey:<parent>"`로 생성되는지 검증합니다.
|
||||||
|
- comparison에서 같은 external key가 다른 domain에 있으면 HALLA domain 기준으로 update/rekey 대상이 되는지 검증합니다.
|
||||||
|
|
||||||
|
- live/E2E
|
||||||
|
- `HALLA_DOMAIN_ID`가 설정된 환경에서 한라 하위 조직 org unit provisioning dry-run을 실행합니다.
|
||||||
|
- 실제 upsert 후 worksmobile 메뉴의 최근 작업에서 processed/failed 및 실패 사유를 확인합니다.
|
||||||
|
|
||||||
|
## 운영 순서 제안
|
||||||
|
|
||||||
|
1. `HALLA_DOMAIN_ID` 환경값을 dev/local에 먼저 설정합니다.
|
||||||
|
2. 코드에 HALLA 도메인 분류를 추가하고 테스트를 통과시킵니다.
|
||||||
|
3. 로컬 DB의 `halla` 타입을 `COMPANY`로 마이그레이션합니다.
|
||||||
|
4. worksmobile comparison에서 한라 하위 조직만 필터링해 예상 도메인과 parent를 확인합니다.
|
||||||
|
5. 조직 upsert를 먼저 수행합니다.
|
||||||
|
6. 실패 작업이 있으면 최근 작업 이력에서 원인을 확인하고 external key 충돌부터 해소합니다.
|
||||||
|
7. 조직이 모두 정상 처리된 뒤 사용자 sync를 진행합니다.
|
||||||
|
|
||||||
|
## 주의점
|
||||||
|
|
||||||
|
- `halla` 회사 테넌트 자체를 org unit으로 만들면 HALLA 도메인 최상위에 “한라산업개발” 조직이 중복으로 생길 수 있습니다.
|
||||||
|
- 기존에 한라 하위 조직 external key가 `BARONGROUP_DOMAIN_ID`에 생성되어 있으면, WORKS API가 같은 external key의 다른 domain 중복을 허용하지 않을 수 있습니다.
|
||||||
|
- 사용자 sync는 조직 upsert가 끝난 뒤 진행해야 `organizations[].orgUnits[].orgUnitId` 참조 실패를 줄일 수 있습니다.
|
||||||
|
- 로컬 DB 타입과 seed 타입이 다르면 이후 seed/마이그레이션 테스트가 계속 흔들릴 수 있으므로 DB 보정이 먼저 필요합니다.
|
||||||
@@ -1,52 +1,64 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getHanmacFamilyTenantOrderRank,
|
getHanmacFamilyTenantOrderRank,
|
||||||
orderHanmacFamilyChildren,
|
orderHanmacFamilyChildren,
|
||||||
orderHanmacFamilyTenants,
|
orderHanmacFamilyTenants,
|
||||||
} from "./hanmacFamilyOrder";
|
} from "./hanmacFamilyOrder";
|
||||||
|
|
||||||
function tenant(name: string, slug: string) {
|
function tenant(name: string, slug: string) {
|
||||||
return { name, slug };
|
return { name, slug };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("hanmac family organization order", () => {
|
describe("hanmac family organization order", () => {
|
||||||
it("orders the top hanmac-family siblings by policy", () => {
|
it("orders the top hanmac-family siblings by policy", () => {
|
||||||
const ordered = orderHanmacFamilyTenants([
|
const ordered = orderHanmacFamilyTenants([
|
||||||
tenant("바론그룹", "baron-group"),
|
tenant("한라산업개발", "halla"),
|
||||||
tenant("한맥기술", "hanmac"),
|
tenant("바론그룹", "baron-group"),
|
||||||
tenant("삼안", "saman"),
|
tenant("한맥기술", "hanmac"),
|
||||||
tenant("총괄기획&기술개발센터", "gpdtdc"),
|
tenant("삼안", "saman"),
|
||||||
]);
|
tenant("총괄기획&기술개발센터", "gpdtdc"),
|
||||||
|
]);
|
||||||
|
|
||||||
expect(ordered.map((item) => item.name)).toEqual([
|
expect(ordered.map((item) => item.name)).toEqual([
|
||||||
"총괄기획&기술개발센터",
|
"총괄기획&기술개발센터",
|
||||||
"삼안",
|
"삼안",
|
||||||
"한맥기술",
|
"한맥기술",
|
||||||
"바론그룹",
|
"바론그룹",
|
||||||
]);
|
"한라산업개발",
|
||||||
});
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps hanmac-family as the root before ordered descendants", () => {
|
it("keeps hanmac-family as the root before ordered descendants", () => {
|
||||||
const family = tenant("한맥가족", "hanmac-family");
|
const family = tenant("한맥가족", "hanmac-family");
|
||||||
const children = orderHanmacFamilyChildren(family, [
|
const children = orderHanmacFamilyChildren(family, [
|
||||||
tenant("바론그룹", "baron-group"),
|
tenant("바론그룹", "baron-group"),
|
||||||
tenant("총괄기획&기술개발센터", "gpdtdc"),
|
tenant("총괄기획&기술개발센터", "gpdtdc"),
|
||||||
tenant("삼안", "saman"),
|
tenant("삼안", "saman"),
|
||||||
tenant("한맥기술", "hanmac"),
|
tenant("한라산업개발", "halla"),
|
||||||
]);
|
tenant("한맥기술", "hanmac"),
|
||||||
|
]);
|
||||||
|
|
||||||
expect([family, ...children].map((item) => item.name)).toEqual([
|
expect([family, ...children].map((item) => item.name)).toEqual([
|
||||||
"한맥가족",
|
"한맥가족",
|
||||||
"총괄기획&기술개발센터",
|
"총괄기획&기술개발센터",
|
||||||
"삼안",
|
"삼안",
|
||||||
"한맥기술",
|
"한맥기술",
|
||||||
"바론그룹",
|
"바론그룹",
|
||||||
]);
|
"한라산업개발",
|
||||||
});
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not rank generic technical centers as GPDTDC", () => {
|
it("does not rank generic technical centers as GPDTDC", () => {
|
||||||
expect(
|
expect(
|
||||||
getHanmacFamilyTenantOrderRank(tenant("기술개발센터", "rnd-center")),
|
getHanmacFamilyTenantOrderRank(
|
||||||
).toBe(Number.MAX_SAFE_INTEGER);
|
tenant("기술개발센터", "rnd-center"),
|
||||||
});
|
),
|
||||||
|
).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks Halla as the fifth hanmac-family company", () => {
|
||||||
|
expect(
|
||||||
|
getHanmacFamilyTenantOrderRank(tenant("한라산업개발", "halla")),
|
||||||
|
).toBe(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,65 +1,67 @@
|
|||||||
export type HanmacFamilyOrderTenant = {
|
export type HanmacFamilyOrderTenant = {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family";
|
export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family";
|
||||||
|
|
||||||
export const HANMAC_FAMILY_TENANT_ORDER = [
|
export const HANMAC_FAMILY_TENANT_ORDER = [
|
||||||
"gpdtdc",
|
"gpdtdc",
|
||||||
"saman",
|
"saman",
|
||||||
"hanmac",
|
"hanmac",
|
||||||
"baron-group",
|
"baron-group",
|
||||||
|
"halla",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function normalizedTenantText(tenant: HanmacFamilyOrderTenant) {
|
function normalizedTenantText(tenant: HanmacFamilyOrderTenant) {
|
||||||
return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
|
return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) {
|
export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) {
|
||||||
return (
|
return (
|
||||||
tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
|
tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
|
||||||
tenant.name.includes("한맥가족")
|
tenant.name.includes("한맥가족")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHanmacFamilyTenantOrderRank(
|
export function getHanmacFamilyTenantOrderRank(
|
||||||
tenant: HanmacFamilyOrderTenant,
|
tenant: HanmacFamilyOrderTenant,
|
||||||
) {
|
) {
|
||||||
const text = normalizedTenantText(tenant);
|
const text = normalizedTenantText(tenant);
|
||||||
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
|
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
|
||||||
if (text.includes("saman") || text.includes("삼안")) return 1;
|
if (text.includes("saman") || text.includes("삼안")) return 1;
|
||||||
if (
|
if (
|
||||||
(text.includes("hanmac") || text.includes("한맥기술")) &&
|
(text.includes("hanmac") || text.includes("한맥기술")) &&
|
||||||
!isHanmacFamilyRootTenant(tenant)
|
!isHanmacFamilyRootTenant(tenant)
|
||||||
) {
|
) {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
|
if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
|
||||||
return Number.MAX_SAFE_INTEGER;
|
if (text.includes("halla") || text.includes("한라산업개발")) return 4;
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
|
export function compareHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
|
||||||
a: T,
|
a: T,
|
||||||
b: T,
|
b: T,
|
||||||
) {
|
) {
|
||||||
const rankDiff =
|
const rankDiff =
|
||||||
getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
|
getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
|
||||||
if (rankDiff !== 0) return rankDiff;
|
if (rankDiff !== 0) return rankDiff;
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
|
export function orderHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
|
||||||
tenants: readonly T[],
|
tenants: readonly T[],
|
||||||
) {
|
) {
|
||||||
return [...tenants].sort(compareHanmacFamilyTenants);
|
return [...tenants].sort(compareHanmacFamilyTenants);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderHanmacFamilyChildren<T extends HanmacFamilyOrderTenant>(
|
export function orderHanmacFamilyChildren<T extends HanmacFamilyOrderTenant>(
|
||||||
parent: HanmacFamilyOrderTenant,
|
parent: HanmacFamilyOrderTenant,
|
||||||
children: readonly T[],
|
children: readonly T[],
|
||||||
) {
|
) {
|
||||||
return isHanmacFamilyRootTenant(parent)
|
return isHanmacFamilyRootTenant(parent)
|
||||||
? orderHanmacFamilyTenants(children)
|
? orderHanmacFamilyTenants(children)
|
||||||
: [...children];
|
: [...children];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,179 +3,237 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
|||||||
import { buildOrgPickerTree } from "./pickerTree";
|
import { buildOrgPickerTree } from "./pickerTree";
|
||||||
|
|
||||||
function tenant(
|
function tenant(
|
||||||
id: string,
|
id: string,
|
||||||
type: string,
|
type: string,
|
||||||
name: string,
|
name: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
parentId?: string,
|
parentId?: string,
|
||||||
): TenantSummary {
|
): TenantSummary {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
description: "",
|
description: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
parentId,
|
parentId,
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
createdAt: "2026-05-11T00:00:00.000Z",
|
createdAt: "2026-05-11T00:00:00.000Z",
|
||||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("buildOrgPickerTree", () => {
|
describe("buildOrgPickerTree", () => {
|
||||||
it("uses the hanmac-family company-group as the default picker root", () => {
|
it("uses the hanmac-family company-group as the default picker root", () => {
|
||||||
const tenants = [
|
const tenants = [
|
||||||
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
|
tenant(
|
||||||
tenant(
|
"wrong-group",
|
||||||
"wrong-company",
|
"COMPANY_GROUP",
|
||||||
"COMPANY",
|
"Wrong Group",
|
||||||
"Wrong Company",
|
"wrong-group",
|
||||||
"wrong-company",
|
),
|
||||||
"wrong-group",
|
tenant(
|
||||||
),
|
"wrong-company",
|
||||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
"COMPANY",
|
||||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
"Wrong Company",
|
||||||
];
|
"wrong-company",
|
||||||
|
"wrong-group",
|
||||||
|
),
|
||||||
|
tenant(
|
||||||
|
"hanmac-family-id",
|
||||||
|
"COMPANY_GROUP",
|
||||||
|
"한맥가족",
|
||||||
|
"hanmac-family",
|
||||||
|
),
|
||||||
|
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||||
|
];
|
||||||
|
|
||||||
const tree = buildOrgPickerTree({
|
const tree = buildOrgPickerTree({
|
||||||
tenants,
|
tenants,
|
||||||
users: [] satisfies UserSummary[],
|
users: [] satisfies UserSummary[],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tree.companyGroupId).toBe("hanmac-family-id");
|
||||||
|
expect(tree.roots).toHaveLength(1);
|
||||||
|
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
|
||||||
|
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||||
|
"saman-id",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(tree.companyGroupId).toBe("hanmac-family-id");
|
it("orders hanmac-family children by the shared organization policy", () => {
|
||||||
expect(tree.roots).toHaveLength(1);
|
const tenants = [
|
||||||
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
|
tenant(
|
||||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
"hanmac-family-id",
|
||||||
"saman-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(
|
||||||
|
"halla-id",
|
||||||
|
"COMPANY",
|
||||||
|
"한라산업개발",
|
||||||
|
"halla",
|
||||||
|
"hanmac-family-id",
|
||||||
|
),
|
||||||
|
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||||
|
tenant(
|
||||||
|
"gpdtdc-id",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"총괄기획&기술개발센터",
|
||||||
|
"gpdtdc",
|
||||||
|
"hanmac-family-id",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
it("orders hanmac-family children by the shared organization policy", () => {
|
const tree = buildOrgPickerTree({
|
||||||
const tenants = [
|
tenants,
|
||||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
users: [] satisfies UserSummary[],
|
||||||
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({
|
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
|
||||||
tenants,
|
"총괄기획&기술개발센터",
|
||||||
users: [] satisfies UserSummary[],
|
"삼안",
|
||||||
|
"한맥기술",
|
||||||
|
"바론그룹",
|
||||||
|
"한라산업개발",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
|
it("scopes descendant filtering by tenant slug", () => {
|
||||||
"총괄기획&기술개발센터",
|
const tenants = [
|
||||||
"삼안",
|
tenant(
|
||||||
"한맥기술",
|
"hanmac-family-id",
|
||||||
"바론그룹",
|
"COMPANY_GROUP",
|
||||||
]);
|
"한맥가족",
|
||||||
});
|
"hanmac-family",
|
||||||
|
),
|
||||||
|
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||||
|
tenant(
|
||||||
|
"planning-id",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"기획팀",
|
||||||
|
"planning",
|
||||||
|
"saman-id",
|
||||||
|
),
|
||||||
|
tenant(
|
||||||
|
"hanmac-id",
|
||||||
|
"COMPANY",
|
||||||
|
"한맥기술",
|
||||||
|
"hanmac",
|
||||||
|
"hanmac-family-id",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
it("scopes descendant filtering by tenant slug", () => {
|
const tree = buildOrgPickerTree({
|
||||||
const tenants = [
|
tenants,
|
||||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
users: [] satisfies UserSummary[],
|
||||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
tenantId: "saman",
|
||||||
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
|
});
|
||||||
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const tree = buildOrgPickerTree({
|
expect(tree.roots).toHaveLength(1);
|
||||||
tenants,
|
expect(tree.roots[0]?.id).toBe("saman-id");
|
||||||
users: [] satisfies UserSummary[],
|
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||||
tenantId: "saman",
|
"planning-id",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(tree.roots).toHaveLength(1);
|
it("excludes internal and private tenants from picker choices by default", () => {
|
||||||
expect(tree.roots[0]?.id).toBe("saman-id");
|
const tenants = [
|
||||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
tenant(
|
||||||
"planning-id",
|
"hanmac-family-id",
|
||||||
]);
|
"COMPANY_GROUP",
|
||||||
});
|
"한맥가족",
|
||||||
|
"hanmac-family",
|
||||||
|
),
|
||||||
|
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||||
|
{
|
||||||
|
...tenant(
|
||||||
|
"internal-id",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"내부 조직",
|
||||||
|
"internal",
|
||||||
|
"saman-id",
|
||||||
|
),
|
||||||
|
config: { visibility: "internal" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...tenant(
|
||||||
|
"secret-id",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"비공개 조직",
|
||||||
|
"secret",
|
||||||
|
"saman-id",
|
||||||
|
),
|
||||||
|
config: { visibility: "private" },
|
||||||
|
},
|
||||||
|
tenant(
|
||||||
|
"secret-child-id",
|
||||||
|
"USER_GROUP",
|
||||||
|
"비공개 하위",
|
||||||
|
"secret-child",
|
||||||
|
"secret-id",
|
||||||
|
),
|
||||||
|
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
||||||
|
];
|
||||||
|
|
||||||
it("excludes internal and private tenants from picker choices by default", () => {
|
const tree = buildOrgPickerTree({
|
||||||
const tenants = [
|
tenants,
|
||||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
users: [] satisfies UserSummary[],
|
||||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
tenantId: "saman",
|
||||||
{
|
});
|
||||||
...tenant(
|
|
||||||
"internal-id",
|
|
||||||
"ORGANIZATION",
|
|
||||||
"내부 조직",
|
|
||||||
"internal",
|
|
||||||
"saman-id",
|
|
||||||
),
|
|
||||||
config: { visibility: "internal" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...tenant(
|
|
||||||
"secret-id",
|
|
||||||
"ORGANIZATION",
|
|
||||||
"비공개 조직",
|
|
||||||
"secret",
|
|
||||||
"saman-id",
|
|
||||||
),
|
|
||||||
config: { visibility: "private" },
|
|
||||||
},
|
|
||||||
tenant(
|
|
||||||
"secret-child-id",
|
|
||||||
"USER_GROUP",
|
|
||||||
"비공개 하위",
|
|
||||||
"secret-child",
|
|
||||||
"secret-id",
|
|
||||||
),
|
|
||||||
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const tree = buildOrgPickerTree({
|
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||||
tenants,
|
"open-id",
|
||||||
users: [] satisfies UserSummary[],
|
]);
|
||||||
tenantId: "saman",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
|
it("includes internal tenants when explicitly requested", () => {
|
||||||
});
|
const tenants = [
|
||||||
|
tenant(
|
||||||
|
"hanmac-family-id",
|
||||||
|
"COMPANY_GROUP",
|
||||||
|
"한맥가족",
|
||||||
|
"hanmac-family",
|
||||||
|
),
|
||||||
|
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||||
|
{
|
||||||
|
...tenant(
|
||||||
|
"internal-id",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"내부 조직",
|
||||||
|
"internal",
|
||||||
|
"saman-id",
|
||||||
|
),
|
||||||
|
config: { visibility: "internal" },
|
||||||
|
},
|
||||||
|
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
||||||
|
];
|
||||||
|
|
||||||
it("includes internal tenants when explicitly requested", () => {
|
const tree = buildOrgPickerTree({
|
||||||
const tenants = [
|
includeInternal: true,
|
||||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
tenants,
|
||||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
users: [] satisfies UserSummary[],
|
||||||
{
|
tenantId: "saman",
|
||||||
...tenant(
|
});
|
||||||
"internal-id",
|
|
||||||
"ORGANIZATION",
|
|
||||||
"내부 조직",
|
|
||||||
"internal",
|
|
||||||
"saman-id",
|
|
||||||
),
|
|
||||||
config: { visibility: "internal" },
|
|
||||||
},
|
|
||||||
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const tree = buildOrgPickerTree({
|
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||||
includeInternal: true,
|
"internal-id",
|
||||||
tenants,
|
"open-id",
|
||||||
users: [] satisfies UserSummary[],
|
]);
|
||||||
tenantId: "saman",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
|
||||||
"internal-id",
|
|
||||||
"open-id",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type
|
tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type
|
||||||
cd1ebc22-4b5e-4242-bb87-eb88db32286c,업무,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,operations,,,public,팀
|
cd1ebc22-4b5e-4242-bb87-eb88db32286c,업무,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,operations,,,public,팀
|
||||||
0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-seosan-recovery,,,public,
|
0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-seosan-recovery,,,public,
|
||||||
41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-anseong-wwtp,,,public,
|
41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-anseong-wwtp,,,public,
|
||||||
ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-onsan-bio,,,public,
|
ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-onsan-bio,,,public,
|
||||||
03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-ulsan-incineration,,,public,
|
03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-ulsan-incineration,,,public,
|
||||||
d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-operation-sites,,,public,
|
d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-operation-sites,,,public,
|
||||||
551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-busan-new-port,,,public,
|
551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-busan-new-port,,,public,
|
||||||
e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gtx-b-4,,,public,
|
e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gtx-b-4,,,public,
|
||||||
3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gyeongsan-road,,,public,
|
3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gyeongsan-road,,,public,
|
||||||
44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-indeokwon-dongtan-7,,,public,
|
44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-7,,,public,
|
||||||
2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-indeokwon-dongtan-3,,,public,
|
2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-3,,,public,
|
||||||
1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-jeju-sewage,,,public,
|
1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jeju-sewage,,,public,
|
||||||
36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-seongnam-waste,,,public,
|
36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-seongnam-waste,,,public,
|
||||||
5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gwangtan-sewage,,,public,
|
5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangtan-sewage,,,public,
|
||||||
d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-incheon-air-cargo,,,public,
|
d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-incheon-air-cargo,,,public,
|
||||||
32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-sudokwon-landfill-2,,,public,
|
32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sudokwon-landfill-2,,,public,
|
||||||
e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-onsan-sewage,,,public,
|
e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-onsan-sewage,,,public,
|
||||||
25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-sincheon-sewage,,,public,
|
25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sincheon-sewage,,,public,
|
||||||
f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-jangnyang-sewage,,,public,
|
f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jangnyang-sewage,,,public,
|
||||||
b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-apo-sewage,,,public,
|
b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-apo-sewage,,,public,
|
||||||
76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gwangju-wastewater,,,public,
|
76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangju-wastewater,,,public,
|
||||||
bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-docheok-silchon-road,,,public,
|
bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-docheok-silchon-road,,,public,
|
||||||
02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-yeoju-bupyeongcheon,,,public,
|
02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-yeoju-bupyeongcheon,,,public,
|
||||||
9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-okjeong-sewage,,,public,
|
9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-okjeong-sewage,,,public,
|
||||||
29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-bucheon-gulpocheon,,,public,
|
29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-bucheon-gulpocheon,,,public,
|
||||||
99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-construction-sites,,,public,
|
99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-construction-sites,,,public,
|
||||||
2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,hanlla-safety-hq,hanlla-safety-team,,,public,
|
2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,halla-safety-hq,halla-safety-team,,,public,
|
||||||
4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-safety-hq,,,public,
|
4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-safety-hq,,,public,
|
||||||
69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,hanlla-tech-sales-hq,hanlla-tech-sales-team,,,public,
|
69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,halla-tech-sales-hq,halla-tech-sales-team,,,public,
|
||||||
1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-tech-sales-hq,,,public,
|
1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-tech-sales-hq,,,public,
|
||||||
6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,hanlla-env-plant-hq,hanlla-env-plant-design,,,public,
|
6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-plant-design,,,public,
|
||||||
69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,hanlla-env-plant-hq,hanlla-env-project-mgmt,,,public,
|
69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-project-mgmt,,,public,
|
||||||
2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-env-plant-hq,,,public,
|
2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-env-plant-hq,,,public,
|
||||||
45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,hanlla-infra-business-hq,hanlla-infra-project-mgmt,,,public,
|
45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,halla-infra-business-hq,halla-infra-project-mgmt,,,public,
|
||||||
1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-infra-business-hq,,,public,
|
1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-infra-business-hq,,,public,
|
||||||
03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-operations-office,,,public,
|
03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations-office,,,public,
|
||||||
6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-mgmt-support,,,public,
|
6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-mgmt-support,,,public,
|
||||||
43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-business-support,,,public,
|
43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-business-support,,,public,
|
||||||
d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-mgmt-support-hq,,,public,
|
d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-mgmt-support-hq,,,public,
|
||||||
940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-general-sales,,,public,
|
940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-sales,,,public,
|
||||||
57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-general-business,,,public,
|
57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-business,,,public,
|
||||||
81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-executive,,,public,
|
81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-executive,,,public,
|
||||||
786dd00c-b0c1-4db9-b25b-1afecd6a7a41,안전관리,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-safety-management,,,public,
|
786dd00c-b0c1-4db9-b25b-1afecd6a7a41,안전관리,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-safety-management,,,public,
|
||||||
5fbf6f2c-6b12-4124-a457-d1064dbb8677,현장,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-site,,,public,
|
5fbf6f2c-6b12-4124-a457-d1064dbb8677,현장,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-site,,,public,
|
||||||
dd82bb7b-43d8-4744-ab65-9b47ea492ac4,공무,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-construction-admin,,,public,
|
dd82bb7b-43d8-4744-ab65-9b47ea492ac4,공무,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-construction-admin,,,public,
|
||||||
@@ -125,7 +125,7 @@ fe58cad4-1fa6-4b87-a2eb-51b9ac41320e,사업개발실,ORGANIZATION,9caf62e1-297d-
|
|||||||
01fcbee1-df33-4ee9-bf2b-6d9eb81917d9,대외협력팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,external-relations,,,public,
|
01fcbee1-df33-4ee9-bf2b-6d9eb81917d9,대외협력팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,external-relations,,,public,
|
||||||
cdc40c0b-f985-461a-be18-f8c8e82f31e8,재무회계팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,finance,,,public,
|
cdc40c0b-f985-461a-be18-f8c8e82f31e8,재무회계팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,finance,,,public,
|
||||||
c6aa2133-ded0-451c-b51b-27faa8b56507,PQ팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,pq-team,,,public,
|
c6aa2133-ded0-451c-b51b-27faa8b56507,PQ팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,pq-team,,,public,
|
||||||
ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-operations,,,public,
|
ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations,,,public,
|
||||||
a16f49c4-6828-4fde-a164-43099c4560c4,기획부,ORGANIZATION,9bf67270-e15e-4278-b407-02dec5672876,business-strategy,planning,,,public,
|
a16f49c4-6828-4fde-a164-43099c4560c4,기획부,ORGANIZATION,9bf67270-e15e-4278-b407-02dec5672876,business-strategy,planning,,,public,
|
||||||
9bf67270-e15e-4278-b407-02dec5672876,경영전략본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,business-strategy,,,public,
|
9bf67270-e15e-4278-b407-02dec5672876,경영전략본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,business-strategy,,,public,
|
||||||
896da8ab-50b7-4a63-abbc-c85037b63acc,시공BIM,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,construction-bim,,,public,
|
896da8ab-50b7-4a63-abbc-c85037b63acc,시공BIM,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,construction-bim,,,public,
|
||||||
@@ -179,7 +179,7 @@ c6b1266c-564b-4543-baba-d78807a3d1b4,경영기획,ORGANIZATION,761a8725-9c19-442
|
|||||||
761a8725-9c19-442c-986c-0319e33a5b1e,총괄기획실,ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,gpd,,,public,
|
761a8725-9c19-442c-986c-0319e33a5b1e,총괄기획실,ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,gpd,,,public,
|
||||||
e57cb22c-383e-4489-8c2f-0c5431917e86,PTC,COMPANY,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,ptc,,,public,
|
e57cb22c-383e-4489-8c2f-0c5431917e86,PTC,COMPANY,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,ptc,,,public,
|
||||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,,personal,개인 사용자 기본 루트 테넌트,,public,
|
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,,personal,개인 사용자 기본 루트 테넌트,,public,
|
||||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,hanlla,,,public,
|
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,halla,,,public,
|
||||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon-sanup,,,public,
|
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon-sanup,,,public,
|
||||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon,,,public,
|
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon,,,public,
|
||||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,,public,
|
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,,public,
|
||||||
|
|||||||
|
Reference in New Issue
Block a user