+
{name ?? "-"}
+ {email &&
{email}
}
+ {slug && (
+
{slug}
+ )}
{id ?? ""}
{details.length > 0 && (
diff --git a/adminfront/src/features/tenants/routes/tenantListView.ts b/adminfront/src/features/tenants/routes/tenantListView.ts
new file mode 100644
index 00000000..d5ebf895
--- /dev/null
+++ b/adminfront/src/features/tenants/routes/tenantListView.ts
@@ -0,0 +1,126 @@
+import type { TenantSummary } from "../../../lib/adminApi";
+import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
+
+export type TenantViewMode = "tree" | "table";
+export type TenantViewRow = TenantNode & { depth: number };
+
+export function tenantMatchesListSearch(
+ tenant: Pick,
+ search: string,
+) {
+ const normalizedSearch = search.trim().toLowerCase();
+ if (!normalizedSearch) return true;
+
+ return [tenant.name, tenant.slug, tenant.id, tenant.type]
+ .filter(Boolean)
+ .some((value) => value.toLowerCase().includes(normalizedSearch));
+}
+
+function collectTenantTreeRows(
+ nodes: TenantNode[],
+ depth: number,
+ rows: TenantViewRow[],
+) {
+ for (const node of nodes) {
+ rows.push({ ...node, depth });
+ collectTenantTreeRows(node.children, depth + 1, rows);
+ }
+}
+
+function collectTenantDescendantIds(
+ tenantId: string,
+ tenants: TenantSummary[],
+) {
+ const childrenByParent = new Map();
+ for (const tenant of tenants) {
+ if (!tenant.parentId) continue;
+ const children = childrenByParent.get(tenant.parentId) ?? [];
+ children.push(tenant);
+ childrenByParent.set(tenant.parentId, children);
+ }
+
+ const ids: string[] = [];
+ const visitedIds = new Set();
+ const visit = (parentId: string) => {
+ for (const child of childrenByParent.get(parentId) ?? []) {
+ if (visitedIds.has(child.id)) continue;
+ visitedIds.add(child.id);
+ ids.push(child.id);
+ visit(child.id);
+ }
+ };
+ visit(tenantId);
+ return ids;
+}
+
+export function filterTenantsByScope(
+ tenants: TenantSummary[],
+ scopeTenantId: string,
+) {
+ if (!scopeTenantId) return tenants;
+ const descendantIds = new Set(
+ collectTenantDescendantIds(scopeTenantId, tenants),
+ );
+ return tenants.filter((tenant) => descendantIds.has(tenant.id));
+}
+
+export function getTenantViewRows(
+ tenants: TenantSummary[],
+ viewMode: TenantViewMode,
+ scopeTenantId = "",
+): TenantViewRow[] {
+ const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
+ const treeRows: TenantViewRow[] = [];
+ collectTenantTreeRows(subTree, 0, treeRows);
+
+ if (viewMode === "tree") {
+ return treeRows;
+ }
+
+ const rowsById = new Map(treeRows.map((row) => [row.id, row]));
+ const flatSource = scopeTenantId
+ ? filterTenantsByScope(tenants, scopeTenantId)
+ : tenants;
+
+ return flatSource.map((tenant) => ({
+ ...(rowsById.get(tenant.id) ?? {
+ ...tenant,
+ children: [],
+ recursiveMemberCount: Number(tenant.memberCount) || 0,
+ }),
+ depth: 0,
+ }));
+}
+
+export function resolveTenantSelectionIds({
+ currentIds,
+ tenant,
+ checked,
+ tenants,
+ deletableTenants,
+}: {
+ currentIds: string[];
+ tenant: TenantSummary;
+ checked: boolean;
+ tenants: TenantSummary[];
+ deletableTenants: TenantSummary[];
+}) {
+ const allowedIds = new Set(deletableTenants.map((item) => item.id));
+ const targetIds = [
+ tenant.id,
+ ...collectTenantDescendantIds(tenant.id, tenants),
+ ].filter((id) => allowedIds.has(id));
+ const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
+
+ if (checked) {
+ for (const id of targetIds) {
+ next.add(id);
+ }
+ } else {
+ for (const id of targetIds) {
+ next.delete(id);
+ }
+ }
+
+ return Array.from(next);
+}
diff --git a/adminfront/src/features/tenants/routes/tenantSchemaFields.ts b/adminfront/src/features/tenants/routes/tenantSchemaFields.ts
new file mode 100644
index 00000000..09b192ea
--- /dev/null
+++ b/adminfront/src/features/tenants/routes/tenantSchemaFields.ts
@@ -0,0 +1,74 @@
+export type SchemaFieldType =
+ | "text"
+ | "number"
+ | "boolean"
+ | "date"
+ | "float"
+ | "datetime";
+
+export type SchemaField = {
+ id: string;
+ key: string;
+ label: string;
+ type: SchemaFieldType;
+ required: boolean;
+ adminOnly: boolean;
+ validation?: string;
+ unsigned?: boolean;
+ isLoginId?: boolean;
+ indexed?: boolean;
+};
+
+function createFieldId() {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ return crypto.randomUUID();
+ }
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
+ return (
+ value === "text" ||
+ value === "number" ||
+ value === "boolean" ||
+ value === "date" ||
+ value === "float" ||
+ value === "datetime"
+ );
+}
+
+export function normalizeSchemaField(field: unknown): SchemaField {
+ const source =
+ typeof field === "object" && field !== null
+ ? (field as Record)
+ : {};
+ const type = isSchemaFieldType(source.type) ? source.type : "text";
+ const isLoginId = Boolean(source.isLoginId);
+
+ return {
+ id: typeof source.id === "string" ? source.id : createFieldId(),
+ key: typeof source.key === "string" ? source.key : "",
+ label: typeof source.label === "string" ? source.label : "",
+ type,
+ required: Boolean(source.required),
+ adminOnly: Boolean(source.adminOnly),
+ validation: typeof source.validation === "string" ? source.validation : "",
+ unsigned: Boolean(source.unsigned),
+ isLoginId,
+ indexed: isLoginId || Boolean(source.indexed),
+ };
+}
+
+export function createSchemaField(): SchemaField {
+ return {
+ id: createFieldId(),
+ key: "",
+ label: "",
+ type: "text",
+ required: false,
+ adminOnly: false,
+ validation: "",
+ unsigned: false,
+ indexed: false,
+ };
+}
diff --git a/adminfront/src/features/tenants/routes/worksmobileComparison.ts b/adminfront/src/features/tenants/routes/worksmobileComparison.ts
new file mode 100644
index 00000000..61979d53
--- /dev/null
+++ b/adminfront/src/features/tenants/routes/worksmobileComparison.ts
@@ -0,0 +1,359 @@
+import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
+
+export type WorksmobileComparisonFilter =
+ | "works_only"
+ | "baron_only"
+ | "matched";
+
+export type WorksmobileComparisonSummary = {
+ total: number;
+ matched: number;
+ missingInWorksmobile: number;
+ missingInBaron: number;
+ missingExternalKey: number;
+};
+
+export type WorksmobileComparisonColumnKey =
+ | "status"
+ | "baronId"
+ | "baron"
+ | "baronOrg"
+ | "worksmobileId"
+ | "externalKey"
+ | "worksmobileDomain"
+ | "worksmobile"
+ | "worksmobileOrg"
+ | "manage";
+
+export type WorksmobileComparisonColumnVisibility = Record<
+ WorksmobileComparisonColumnKey,
+ boolean
+>;
+
+export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
+ return {
+ status: true,
+ baronId: false,
+ baron: true,
+ baronOrg: true,
+ worksmobileId: false,
+ externalKey: false,
+ worksmobileDomain: true,
+ worksmobile: true,
+ worksmobileOrg: true,
+ manage: true,
+ };
+}
+
+export function summarizeWorksmobileComparison(
+ rows: WorksmobileComparisonItem[],
+): WorksmobileComparisonSummary {
+ return rows.reduce(
+ (summary, row) => {
+ if (row.status === "matched") {
+ summary.matched += 1;
+ } else if (row.status === "missing_in_worksmobile") {
+ summary.missingInWorksmobile += 1;
+ } else if (row.status === "missing_in_baron") {
+ summary.missingInBaron += 1;
+ } else if (row.status === "missing_external_key") {
+ summary.missingExternalKey += 1;
+ }
+ return summary;
+ },
+ {
+ total: rows.length,
+ matched: 0,
+ missingInWorksmobile: 0,
+ missingInBaron: 0,
+ missingExternalKey: 0,
+ },
+ );
+}
+
+export function getWorksmobileComparisonStatusLabel(status: string) {
+ switch (status) {
+ case "matched":
+ return "일치";
+ case "missing_in_worksmobile":
+ return "WORKS 없음";
+ case "missing_in_baron":
+ return "Baron 없음";
+ case "missing_external_key":
+ return "ex_key 없음";
+ default:
+ return status;
+ }
+}
+
+export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
+ return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
+}
+
+const immutableWorksmobileAccountEmails = new Set([
+ "cyhan@samaneng.com",
+ "cyhan1@hanmaceng.co.kr",
+ "cyhan2@baroncs.co.kr",
+ "cyhan3@brsw.kr",
+ "su-@samaneng.com",
+]);
+
+const hiddenWorksmobileMemberEmails = new Set([
+ "su-@samaneng.com",
+ "cyhan1@hanmaceng.co.kr",
+ "cyhan2@baroncs.co.kr",
+ "cyhan3@brsw.kr",
+]);
+
+function normalizeWorksmobileEmail(email?: string) {
+ return email?.trim().toLowerCase() ?? "";
+}
+
+export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
+ return (
+ row.resourceType === "USER" &&
+ immutableWorksmobileAccountEmails.has(
+ normalizeWorksmobileEmail(row.worksmobileEmail),
+ )
+ );
+}
+
+export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
+ if (row.resourceType !== "USER") {
+ return false;
+ }
+
+ return [row.worksmobileEmail, row.baronEmail].some((email) =>
+ hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
+ );
+}
+
+export function filterVisibleWorksmobileComparisonRows(
+ rows: WorksmobileComparisonItem[],
+) {
+ return rows.filter((row) => !isHiddenWorksmobileMember(row));
+}
+
+export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
+ if (row.baronId) {
+ return `${row.resourceType}:baron:${row.baronId}`;
+ }
+ if (row.worksmobileId) {
+ return `${row.resourceType}:works:${row.worksmobileId}`;
+ }
+ if (row.externalKey) {
+ return `${row.resourceType}:external:${row.externalKey}`;
+ }
+ return "";
+}
+
+export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
+ return (
+ Boolean(getWorksmobileRowSelectionKey(row)) &&
+ !isImmutableWorksmobileAccount(row)
+ );
+}
+
+export function getWorksmobileSelectedActionIds(
+ rows: WorksmobileComparisonItem[],
+ selectedKeys: string[],
+) {
+ const selected = new Set(selectedKeys);
+ return rows
+ .filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
+ .map((row) => row.baronId)
+ .filter((id): id is string => Boolean(id));
+}
+
+export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
+ rows: WorksmobileComparisonItem[],
+ selectedKeys: string[],
+) {
+ return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
+ (id) =>
+ rows.some(
+ (row) =>
+ row.worksmobileId === id && row.status === "missing_external_key",
+ ),
+ );
+}
+
+export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
+ rows: WorksmobileComparisonItem[],
+ selectedKeys: string[],
+) {
+ const selected = new Set(selectedKeys);
+ return rows
+ .filter(
+ (row) =>
+ row.resourceType === "GROUP" &&
+ (row.status === "missing_external_key" ||
+ row.status === "missing_in_baron") &&
+ selected.has(getWorksmobileRowSelectionKey(row)),
+ )
+ .map((row) => row.worksmobileId)
+ .filter((id): id is string => Boolean(id));
+}
+
+const worksmobileComparisonSearchFields: Array<
+ keyof WorksmobileComparisonItem
+> = [
+ "baronId",
+ "baronSlug",
+ "baronName",
+ "baronEmail",
+ "baronPrimaryOrgId",
+ "baronPrimaryOrgSlug",
+ "baronPrimaryOrgName",
+ "baronParentId",
+ "baronParentSlug",
+ "baronParentName",
+ "worksmobileId",
+ "externalKey",
+ "worksmobileName",
+ "worksmobileEmail",
+ "worksmobileLevelId",
+ "worksmobileLevelName",
+ "worksmobileTask",
+ "worksmobileDomainId",
+ "worksmobileDomainName",
+ "worksmobilePrimaryOrgId",
+ "worksmobilePrimaryOrgName",
+ "worksmobilePrimaryOrgPositionId",
+ "worksmobilePrimaryOrgPositionName",
+ "baronParentWorksmobileId",
+ "baronParentWorksmobileName",
+ "baronParentWorksmobileEmail",
+ "worksmobileParentId",
+ "worksmobileParentName",
+ "worksmobileParentEmail",
+ "worksmobileParentExternalKey",
+];
+
+export function filterWorksmobileComparisonRowsBySearch(
+ rows: WorksmobileComparisonItem[],
+ search: string,
+) {
+ const keyword = search.trim().toLowerCase();
+ if (!keyword) {
+ return rows;
+ }
+ return rows.filter((row) =>
+ worksmobileComparisonSearchFields.some((field) => {
+ const value = row[field];
+ if (value === undefined || value === null) {
+ return false;
+ }
+ return String(value).toLowerCase().includes(keyword);
+ }),
+ );
+}
+
+export function filterWorksmobileComparisonRows(
+ rows: WorksmobileComparisonItem[],
+ filters: WorksmobileComparisonFilter[],
+ onlyMissingExternalKey = false,
+) {
+ const allowedStatuses = new Set(
+ filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
+ );
+ if (filters.includes("works_only")) {
+ if (onlyMissingExternalKey) {
+ allowedStatuses.delete("missing_in_baron");
+ }
+ allowedStatuses.add("missing_external_key");
+ }
+ return rows.filter((row) => allowedStatuses.has(row.status));
+}
+
+export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
+ return [
+ row.worksmobileName,
+ row.worksmobileLevelName ?? row.worksmobileLevelId,
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
+
+export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
+ const details: string[] = [];
+ const position =
+ row.worksmobilePrimaryOrgPositionName ??
+ row.worksmobilePrimaryOrgPositionId;
+ if (position) {
+ details.push(`직책 ${position}`);
+ }
+ if (row.worksmobileTask) {
+ details.push(`직무 ${row.worksmobileTask}`);
+ }
+ if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
+ details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
+ }
+ return details;
+}
+
+export function buildWorksmobilePasswordManageUrl({
+ tenantId,
+ domainId,
+ userIdNo,
+}: {
+ tenantId?: string;
+ domainId?: number;
+ userIdNo?: string;
+}) {
+ const normalizedTenantId = tenantId?.trim();
+ const normalizedUserIdNo = userIdNo?.trim();
+ if (
+ !normalizedTenantId ||
+ !domainId ||
+ domainId <= 0 ||
+ !normalizedUserIdNo
+ ) {
+ return "";
+ }
+ const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
+ url.searchParams.set("usage", "admin");
+ url.searchParams.set("targetUserTenantId", normalizedTenantId);
+ url.searchParams.set("targetUserDomainId", String(domainId));
+ url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
+ url.searchParams.set(
+ "accessUrl",
+ "https://admin.worksmobile.com/assets/self-close.html",
+ );
+ return url.toString();
+}
+
+export function canOpenWorksmobilePasswordManage(
+ row: WorksmobileComparisonItem,
+ tenantId?: string,
+) {
+ return (
+ row.resourceType === "USER" &&
+ !isImmutableWorksmobileAccount(row) &&
+ Boolean(
+ buildWorksmobilePasswordManageUrl({
+ tenantId,
+ domainId: row.worksmobileDomainId,
+ userIdNo: row.worksmobileId,
+ }),
+ )
+ );
+}
+
+export const comparisonFilterOptions: Array<{
+ value: WorksmobileComparisonFilter;
+ label: string;
+}> = [
+ { value: "baron_only", label: "바론에만 있음" },
+ { value: "works_only", label: "웍스에만 있음" },
+ { value: "matched", label: "양쪽 다 있음" },
+];
+
+export const userFilterOptions = comparisonFilterOptions;
+
+const worksmobileFilterStatuses: Record =
+ {
+ baron_only: ["missing_in_worksmobile"],
+ works_only: ["missing_in_baron"],
+ matched: ["matched"],
+ };
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 8c915492..a88a495f 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -789,11 +789,14 @@ export type WorksmobileOverview = {
export type WorksmobileComparisonItem = {
resourceType: string;
baronId?: string;
+ baronSlug?: string;
baronName?: string;
baronEmail?: string;
baronPrimaryOrgId?: string;
+ baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string;
baronParentId?: string;
+ baronParentSlug?: string;
baronParentName?: string;
worksmobileId?: string;
externalKey?: string;
@@ -809,8 +812,13 @@ export type WorksmobileComparisonItem = {
worksmobilePrimaryOrgPositionId?: string;
worksmobilePrimaryOrgPositionName?: string;
worksmobilePrimaryOrgIsManager?: boolean;
+ baronParentWorksmobileId?: string;
+ baronParentWorksmobileName?: string;
+ baronParentWorksmobileEmail?: string;
worksmobileParentId?: string;
worksmobileParentName?: string;
+ worksmobileParentEmail?: string;
+ worksmobileParentExternalKey?: string;
status: string;
};
@@ -924,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
orgUnitId: string,
) {
const { data } = await apiClient.post(
- `/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
+ `/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/sync`,
+ );
+ return data;
+}
+
+export async function enqueueWorksmobileOrgUnitDelete(
+ tenantId: string,
+ orgUnitId: string,
+) {
+ const { data } = await apiClient.post(
+ `/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/delete`,
);
return data;
}
diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml
index 8605e86d..f3613258 100644
--- a/adminfront/src/locales/en.toml
+++ b/adminfront/src/locales/en.toml
@@ -935,7 +935,7 @@ start_import = "Start Import"
kicker = "Global Overview"
[ui.admin.overview.chart]
-description = "Check the graph by all or selected organizations."
+description = "Check the graph by all or selected companies."
title = "Login request status by company and app"
[ui.admin.overview.playbook]
diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml
index 0c66c36e..279b2106 100644
--- a/adminfront/src/locales/ko.toml
+++ b/adminfront/src/locales/ko.toml
@@ -937,7 +937,7 @@ start_import = "임포트 시작"
kicker = "Global Overview"
[ui.admin.overview.chart]
-description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
+description = "전체 또는 선택한 회사 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.admin.overview.playbook]
diff --git a/adminfront/tailwind.config.ts b/adminfront/tailwind.config.ts
index 3f8ed216..401a9bd8 100644
--- a/adminfront/tailwind.config.ts
+++ b/adminfront/tailwind.config.ts
@@ -6,7 +6,8 @@ const config: Config = {
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
- "../common/**/*.{ts,tsx,css}",
+ "../common/core/**/*.{ts,tsx}",
+ "../common/shell/**/*.{ts,tsx}",
],
};
diff --git a/adminfront/tests/shell_layout.spec.ts b/adminfront/tests/shell_layout.spec.ts
new file mode 100644
index 00000000..a6566975
--- /dev/null
+++ b/adminfront/tests/shell_layout.spec.ts
@@ -0,0 +1,80 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("Admin shell layout", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ window.localStorage.setItem("locale", "ko");
+ window.localStorage.setItem("admin_session", "fake-token");
+ (
+ window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
+ )._IS_TEST_MODE = true;
+
+ const authority = "http://localhost:5000/oidc";
+ const client_id = "adminfront";
+ const key = `oidc.user:${authority}:${client_id}`;
+ window.localStorage.setItem(
+ key,
+ JSON.stringify({
+ access_token: "fake-token",
+ token_type: "Bearer",
+ profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
+ expires_at: Math.floor(Date.now() / 1000) + 36000,
+ }),
+ );
+ });
+
+ await page.route("**/api/v1/**", async (route) => {
+ const url = route.request().url();
+ const headers = { "Access-Control-Allow-Origin": "*" };
+
+ if (url.includes("/user/me")) {
+ return route.fulfill({
+ json: {
+ id: "admin-user",
+ name: "Admin",
+ role: "super_admin",
+ manageableTenants: [],
+ },
+ headers,
+ });
+ }
+
+ if (url.includes("/admin/tenants")) {
+ return route.fulfill({
+ json: { items: [], total: 0, limit: 1000, offset: 0 },
+ headers,
+ });
+ }
+
+ return route.fulfill({ json: { items: [], total: 0 }, headers });
+ });
+
+ await page.route("**/oidc/**", async (route) => {
+ await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
+ });
+ });
+
+ test("keeps navigation in the left sidebar without covering content", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 900, height: 700 });
+ await page.goto("/tenants");
+
+ const sidebar = page.locator("aside").first();
+ const main = page.locator("main").first();
+
+ await expect(sidebar).toBeVisible();
+ await expect(main).toBeVisible();
+
+ const sidebarBox = await sidebar.boundingBox();
+ const mainBox = await main.boundingBox();
+
+ expect(sidebarBox).not.toBeNull();
+ expect(mainBox).not.toBeNull();
+ expect(sidebarBox?.x).toBeLessThanOrEqual(1);
+ expect(sidebarBox?.width).toBeLessThanOrEqual(260);
+ expect(mainBox?.x).toBeGreaterThanOrEqual(
+ (sidebarBox?.x ?? 0) + (sidebarBox?.width ?? 0) - 1,
+ );
+ });
+});
diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts
index 5cbb05f2..7c7e6506 100644
--- a/adminfront/tests/tenants.spec.ts
+++ b/adminfront/tests/tenants.spec.ts
@@ -105,6 +105,79 @@ test.describe("Tenants Management", () => {
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
});
+ test("switches tree and flat views, searches UUID, and selects descendants", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1100, height: 760 });
+
+ await page.route("**/api/v1/admin/tenants**", async (route) => {
+ if (route.request().method() !== "GET") {
+ return route.continue();
+ }
+
+ await route.fulfill({
+ json: {
+ items: [
+ {
+ id: "company-1",
+ name: "Hanmac",
+ slug: "hanmac",
+ status: "active",
+ type: "COMPANY",
+ memberCount: 0,
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: "dept-1",
+ name: "Planning",
+ slug: "planning",
+ status: "active",
+ type: "ORGANIZATION",
+ parentId: "company-1",
+ memberCount: 0,
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: "team-1",
+ name: "Platform",
+ slug: "platform",
+ status: "active",
+ type: "USER_GROUP",
+ parentId: "dept-1",
+ memberCount: 0,
+ updatedAt: new Date().toISOString(),
+ },
+ ],
+ total: 3,
+ limit: 500,
+ offset: 0,
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ });
+
+ await page.goto("/tenants");
+
+ await expect(page.getByTestId("tenant-view-tree-btn")).toBeVisible();
+ await page.getByTestId("tenant-view-table-btn").click();
+ await expect(page.getByTestId("tenant-view-table-btn")).toBeVisible();
+
+ await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
+ await expect(page.locator("table")).toContainText("Platform");
+ await expect(page.locator("table")).not.toContainText("Hanmac");
+
+ await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
+ await page
+ .locator("tbody tr")
+ .filter({ hasText: "Hanmac" })
+ .getByRole("checkbox")
+ .click();
+
+ await expect(page.getByTestId("tenant-bulk-action-bar")).toContainText(
+ "3개 선택됨",
+ );
+ });
+
test("should virtualize large tenant lists and load next pages automatically", async ({
page,
}) => {
diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts
index 294b5dc7..ff26890d 100644
--- a/adminfront/tests/worksmobile.spec.ts
+++ b/adminfront/tests/worksmobile.spec.ts
@@ -133,6 +133,24 @@ test.describe("Worksmobile tenant management", () => {
worksmobileName: "박웍스",
status: "missing_in_baron",
},
+ {
+ resourceType: "USER",
+ worksmobileId: "works-hidden-su",
+ externalKey: "works-hidden-su",
+ worksmobileName: "숨김 SU",
+ worksmobileEmail: "su-@samaneng.com",
+ status: "missing_in_baron",
+ },
+ {
+ resourceType: "USER",
+ baronId: "user-hidden-cyhan1",
+ baronName: "숨김 CYHAN1",
+ baronEmail: "cyhan1@hanmaceng.co.kr",
+ worksmobileId: "works-hidden-cyhan1",
+ worksmobileName: "숨김 CYHAN1",
+ worksmobileEmail: "cyhan1@hanmaceng.co.kr",
+ status: "matched",
+ },
]
: [
{
@@ -148,6 +166,14 @@ test.describe("Worksmobile tenant management", () => {
worksmobileName: "박웍스",
status: "missing_in_baron",
},
+ {
+ resourceType: "USER",
+ worksmobileId: "works-hidden-su",
+ externalKey: "works-hidden-su",
+ worksmobileName: "숨김 SU",
+ worksmobileEmail: "su-@samaneng.com",
+ status: "missing_in_baron",
+ },
],
groups: [
{
@@ -198,6 +224,10 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("SCIM token")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
+ await expect(page.getByText("숨김 SU")).not.toBeVisible();
+ await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible();
+ await expect(page.getByText("su-@samaneng.com")).not.toBeVisible();
+ await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible();
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
@@ -206,7 +236,16 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("홍길동")).not.toBeVisible();
expect(comparisonRequests[0]).toBe(true);
- const filterButtons = page
+ await page
+ .getByPlaceholder("구성원 이름 또는 UUID 검색")
+ .fill("su-@samaneng.com");
+ await expect(page.getByText("숨김 SU")).not.toBeVisible();
+ await page.getByPlaceholder("구성원 이름 또는 UUID 검색").fill("");
+
+ const userComparisonSection = page
+ .getByRole("heading", { name: "구성원" })
+ .locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]");
+ const filterButtons = userComparisonSection
.getByRole("button", {
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
})
@@ -215,12 +254,16 @@ test.describe("Worksmobile tenant management", () => {
.poll(() => filterButtons)
.toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]);
- await page.getByRole("button", { name: "웍스에만 있음" }).click();
+ await userComparisonSection
+ .getByRole("button", { name: "웍스에만 있음" })
+ .click();
await expect(page.getByText("박웍스")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
- await page.getByRole("button", { name: "양쪽 다 있음" }).click();
+ await userComparisonSection
+ .getByRole("button", { name: "양쪽 다 있음" })
+ .click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
@@ -229,22 +272,30 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
- await page.getByRole("button", { name: "바론에만 있음" }).click();
+ await userComparisonSection
+ .getByRole("button", { name: "바론에만 있음" })
+ .click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
- await page.getByRole("button", { name: "웍스에만 있음" }).click();
+ await userComparisonSection
+ .getByRole("button", { name: "웍스에만 있음" })
+ .click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
- await page.getByRole("button", { name: "양쪽 다 있음" }).click();
+ await userComparisonSection
+ .getByRole("button", { name: "양쪽 다 있음" })
+ .click();
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
- await page.getByRole("button", { name: "바론에만 있음" }).click();
+ await userComparisonSection
+ .getByRole("button", { name: "바론에만 있음" })
+ .click();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
@@ -464,11 +515,13 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("button", { name: "컬럼 설정" });
await userColumnButton.click();
- const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" });
- await dialog.getByLabel("Baron ID").check();
- await dialog.getByLabel("WORKS ID").check();
- await dialog.getByLabel("external_key").check();
- await dialog.getByRole("button", { name: "닫기" }).click();
+ const settingsPanel = page
+ .getByText("구성원 컬럼 설정")
+ .locator("xpath=ancestor::*[@role='dialog'][1]");
+ await settingsPanel.getByLabel("Baron ID").check();
+ await settingsPanel.getByLabel("WORKS", { exact: true }).check();
+ await settingsPanel.getByLabel("external_key").check();
+ await settingsPanel.getByRole("button", { name: "닫기" }).click();
const pageOverflow = await page.evaluate(() => ({
documentScrollWidth: document.documentElement.scrollWidth,
diff --git a/adminfront/vitest.config.ts b/adminfront/vitest.config.ts
index c21934b8..cb00ad4b 100644
--- a/adminfront/vitest.config.ts
+++ b/adminfront/vitest.config.ts
@@ -12,4 +12,9 @@ export default defineConfig({
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
+ server: {
+ fs: {
+ allow: [".."],
+ },
+ },
});
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 6e62480c..b5f7b039 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -751,6 +751,7 @@ func main() {
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
+ admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
diff --git a/backend/internal/handler/worksmobile_handler.go b/backend/internal/handler/worksmobile_handler.go
index c019d95c..15bdc89d 100644
--- a/backend/internal/handler/worksmobile_handler.go
+++ b/backend/internal/handler/worksmobile_handler.go
@@ -61,6 +61,15 @@ func (h *WorksmobileHandler) SyncOrgUnit(c *fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(job)
}
+func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
+ orgUnitID := strings.TrimSpace(c.Params("orgUnitId"))
+ job, err := h.Service.EnqueueOrgUnitDelete(c.Context(), strings.TrimSpace(c.Params("tenantId")), orgUnitID)
+ if err != nil {
+ return worksmobileGuardError(c, err, "delete_orgunit", "org_unit_id", orgUnitID)
+ }
+ return c.Status(fiber.StatusAccepted).JSON(job)
+}
+
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId"))
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
diff --git a/backend/internal/handler/worksmobile_handler_test.go b/backend/internal/handler/worksmobile_handler_test.go
index 80de4f19..bcaafc7d 100644
--- a/backend/internal/handler/worksmobile_handler_test.go
+++ b/backend/internal/handler/worksmobile_handler_test.go
@@ -112,6 +112,10 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitSync(ctx context.Context, te
return &domain.WorksmobileOutbox{ID: "job-orgunit", ResourceID: orgUnitID}, nil
}
+func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
+ return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
+}
+
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
if f.syncUserErr != nil {
return nil, f.syncUserErr
diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go
index 9a8fe642..910bc512 100644
--- a/backend/internal/service/worksmobile_client.go
+++ b/backend/internal/service/worksmobile_client.go
@@ -29,6 +29,7 @@ const (
type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error
+ DeleteOrgUnit(ctx context.Context, orgUnitID string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error
@@ -186,6 +187,9 @@ func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, o
}
func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error {
+ if payload.DisplayOrder < 1 {
+ payload.DisplayOrder = 1
+ }
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
}
@@ -198,11 +202,12 @@ func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload Works
}
func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
- localPart := worksmobileMailLocalPart(matchLocalPart)
groups, err := c.ListGroups(ctx)
if err != nil {
return err
}
+ normalizedMatchLocalPart := worksmobileMailLocalPart(matchLocalPart)
+ var localPartMatch *WorksmobileRemoteGroup
for _, group := range groups {
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
continue
@@ -216,43 +221,24 @@ func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx contex
}
return c.PatchOrgUnit(ctx, group.ID, NewWorksmobileOrgUnitPatchPayload(payload))
}
- }
- if localPart == "" {
- return fmt.Errorf("worksmobile orgunit local-part match key is required")
- }
- matches := make([]WorksmobileRemoteGroup, 0, 1)
- for _, group := range groups {
- if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
- continue
- }
- if group.MailLocalPart == localPart {
- matches = append(matches, group)
+ if normalizedMatchLocalPart != "" && worksmobileMailLocalPart(group.MailLocalPart) == normalizedMatchLocalPart {
+ matched := group
+ if localPartMatch != nil && localPartMatch.ID != matched.ID {
+ return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", normalizedMatchLocalPart)
+ }
+ localPartMatch = &matched
}
}
- if len(matches) == 0 {
- return fmt.Errorf("worksmobile orgunit local-part match not found: %s", localPart)
- }
- if len(matches) > 1 {
- return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", localPart)
- }
- remote := matches[0]
- if strings.TrimSpace(remote.ID) == "" {
- return fmt.Errorf("worksmobile orgunit id is missing for local-part: %s", localPart)
- }
- if strings.TrimSpace(remote.ExternalID) != "" {
- if remote.ExternalID == payload.OrgUnitExternalKey {
+ if localPartMatch != nil {
+ if strings.TrimSpace(localPartMatch.ID) == "" {
return nil
}
- return fmt.Errorf("worksmobile orgunit external key already exists for local-part %s: %s", localPart, remote.ExternalID)
+ if delay := c.orgUnitWriteDelay(); delay > 0 {
+ time.Sleep(delay)
+ }
+ return c.PatchOrgUnit(ctx, localPartMatch.ID, NewWorksmobileOrgUnitPatchPayload(payload))
}
- if delay := c.orgUnitWriteDelay(); delay > 0 {
- time.Sleep(delay)
- }
- patch := NewWorksmobileOrgUnitPatchPayload(payload)
- if patch.Email == "" {
- patch.Email = remote.Email
- }
- return c.PatchOrgUnit(ctx, remote.ID, patch)
+ return fmt.Errorf("worksmobile orgunit external key match not found after create conflict: %s", payload.OrgUnitExternalKey)
}
func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error {
diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go
index 622f62fb..e7f0a195 100644
--- a/backend/internal/service/worksmobile_client_test.go
+++ b/backend/internal/service/worksmobile_client_test.go
@@ -325,6 +325,7 @@ func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path)
+ require.Contains(t, string(transport.requestBodies[0]), `"displayOrder":1`)
require.Equal(t, http.MethodGet, transport.requests[1].Method)
require.Equal(t, "/v1.0/orgunits", transport.requests[1].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
@@ -332,6 +333,34 @@ func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
}
+func TestWorksmobileHTTPClientUpsertOrgUnitDoesNotBackfillExternalKeyByName(t *testing.T) {
+ transport := &captureRoundTripper{
+ responses: []captureResponse{
+ {statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
+ {statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"기술개발센터","email":"legacy-tech@samaneng.com"}],"responseMetaData":{}}`},
+ },
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ DomainIDs: []int64{300285955},
+ HTTPClient: &http.Client{Transport: transport},
+ OrgUnitWriteDelay: -1,
+ }
+
+ err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{
+ DomainID: 300285955,
+ OrgUnitName: "기술개발센터",
+ OrgUnitExternalKey: "tenant-tech-dev-center",
+ DisplayOrder: 1,
+ }, "tech-dev-center")
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "external key match not found")
+ require.Len(t, transport.requests, 2)
+ require.Equal(t, http.MethodGet, transport.requests[1].Method)
+}
+
func TestWorksmobileHTTPClientUpsertOrgUnitTreatsExistingExternalKeyConflictAsSuccess(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
@@ -463,6 +492,29 @@ func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testin
require.Equal(t, []string{"tester@samaneng.com"}, client.activeUsers)
}
+func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testing.T) {
+ repo := &fakeWorksmobileOutboxRepo{
+ ready: []domain.WorksmobileOutbox{
+ {
+ ID: "job-1",
+ ResourceType: domain.WorksmobileResourceOrgUnit,
+ ResourceID: "works-org-1",
+ Action: domain.WorksmobileActionDelete,
+ Status: domain.WorksmobileOutboxStatusPending,
+ Payload: domain.JSONMap{"worksmobileId": "works-org-1"},
+ },
+ },
+ }
+ client := &fakeWorksmobileDirectoryClient{}
+ worker := NewWorksmobileRelayWorker(repo, client)
+
+ err := worker.ProcessOnce(context.Background())
+
+ require.NoError(t, err)
+ require.Equal(t, []string{"job-1"}, repo.processedIDs)
+ require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
+}
+
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
@@ -564,8 +616,8 @@ func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
parentID := "tenant-parent"
childID := "tenant-child"
localTenants := []domain.Tenant{
- {ID: parentID, Name: "기술본부", Type: domain.TenantTypeOrganization},
- {ID: childID, Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
+ {ID: parentID, Slug: "tech-hq", Name: "기술본부", Type: domain.TenantTypeOrganization},
+ {ID: childID, Slug: "tech-planning", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
}
remoteGroups := []WorksmobileRemoteGroup{
{
@@ -589,7 +641,9 @@ func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
items := compareWorksmobileGroups(localTenants, remoteGroups, true)
require.Len(t, items, 2)
+ require.Equal(t, "tech-planning", items[1].BaronSlug)
require.Equal(t, parentID, items[1].BaronParentID)
+ require.Equal(t, "tech-hq", items[1].BaronParentSlug)
require.Equal(t, "기술본부", items[1].BaronParentName)
require.Equal(t, int64(300286337), items[1].WorksmobileDomainID)
require.Equal(t, "총괄기획&기술개발센터", items[1].WorksmobileDomainName)
@@ -638,7 +692,7 @@ func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenInclu
require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName)
}
-func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *testing.T) {
+func TestCompareWorksmobileGroupsDoesNotMatchBySlugLocalPartWhenExternalIDMissing(t *testing.T) {
localTenants := []domain.Tenant{
{
ID: "tenant-tech-dev-center",
@@ -659,12 +713,75 @@ func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *
diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false)
all := compareWorksmobileGroups(localTenants, remoteGroups, true)
- require.Empty(t, diffOnly)
- require.Len(t, all, 1)
- require.Equal(t, "matched", all[0].Status)
+ require.Len(t, diffOnly, 2)
+ require.Equal(t, "missing_in_worksmobile", diffOnly[0].Status)
+ require.Equal(t, "tenant-tech-dev-center", diffOnly[0].BaronID)
+ require.Equal(t, "missing_external_key", diffOnly[1].Status)
+ require.Equal(t, "works-org-1", diffOnly[1].WorksmobileID)
+ require.Len(t, all, 2)
+ require.Equal(t, "missing_in_worksmobile", all[0].Status)
require.Equal(t, "tenant-tech-dev-center", all[0].BaronID)
- require.Equal(t, "works-org-1", all[0].WorksmobileID)
- require.Empty(t, all[0].ExternalKey)
+ require.Equal(t, "missing_external_key", all[1].Status)
+ require.Equal(t, "works-org-1", all[1].WorksmobileID)
+ require.Empty(t, all[1].ExternalKey)
+}
+
+func TestCompareWorksmobileGroupsDoesNotMatchByNameWhenExternalIDAndSlugAreMissing(t *testing.T) {
+ localTenants := []domain.Tenant{
+ {
+ ID: "tenant-tech-dev-center",
+ Slug: "tech-dev-center",
+ Name: "기술개발센터",
+ Type: domain.TenantTypeOrganization,
+ },
+ }
+ remoteGroups := []WorksmobileRemoteGroup{
+ {
+ ID: "works-org-1",
+ DisplayName: "기술개발센터",
+ },
+ }
+
+ items := compareWorksmobileGroups(localTenants, remoteGroups, false)
+
+ require.Len(t, items, 2)
+ require.Equal(t, "missing_in_worksmobile", items[0].Status)
+ require.Equal(t, "tenant-tech-dev-center", items[0].BaronID)
+ require.Equal(t, "missing_external_key", items[1].Status)
+ require.Equal(t, "works-org-1", items[1].WorksmobileID)
+}
+
+func TestCompareWorksmobileGroupsListsExternalKeyMissingRowsAsDeleteCandidatesAcrossDomains(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("HANMAC_DOMAIN_ID", "1002")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
+ rootID := "root-tenant"
+ samanID := "company-saman"
+ hanmacID := "company-hanmac"
+ localTenants := []domain.Tenant{
+ {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ {ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ {ID: hanmacID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
+ {ID: "tenant-saman-planning", Slug: "planning", Name: "기획팀", Type: domain.TenantTypeOrganization, ParentID: &samanID},
+ {ID: "tenant-hanmac-planning", Slug: "planning", Name: "기획팀", Type: domain.TenantTypeOrganization, ParentID: &hanmacID},
+ }
+ remoteGroups := []WorksmobileRemoteGroup{
+ {ID: "works-saman-planning", DomainID: 1001, DisplayName: "기획팀", MailLocalPart: "planning"},
+ {ID: "works-hanmac-planning", DomainID: 1002, DisplayName: "기획팀", MailLocalPart: "planning"},
+ }
+
+ items := compareWorksmobileGroups(localTenants, remoteGroups, false)
+
+ require.Len(t, items, 4)
+ require.Equal(t, "tenant-saman-planning", items[0].BaronID)
+ require.Equal(t, "missing_in_worksmobile", items[0].Status)
+ require.Equal(t, "tenant-hanmac-planning", items[1].BaronID)
+ require.Equal(t, "missing_in_worksmobile", items[1].Status)
+ require.Equal(t, "works-saman-planning", items[2].WorksmobileID)
+ require.Equal(t, "missing_external_key", items[2].Status)
+ require.Equal(t, "works-hanmac-planning", items[3].WorksmobileID)
+ require.Equal(t, "missing_external_key", items[3].Status)
}
func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) {
@@ -802,11 +919,13 @@ func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, m
type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
+ deletedOrgUnits []string
createdUsers []WorksmobileUserPayload
deletedUsers []string
activeUsers []string
suspendedUsers []string
orgUnitMatchKeys []string
+ groups []WorksmobileRemoteGroup
}
type captureRoundTripper struct {
@@ -880,6 +999,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertOrgUnit(ctx context.Context, payl
return nil
}
+func (f *fakeWorksmobileDirectoryClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
+ f.deletedOrgUnits = append(f.deletedOrgUnits, orgUnitID)
+ return nil
+}
+
func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
f.createdUsers = append(f.createdUsers, payload)
return nil
@@ -909,5 +1033,5 @@ func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]Works
}
func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
- return nil, nil
+ return f.groups, nil
}
diff --git a/backend/internal/service/worksmobile_live_flow_test.go b/backend/internal/service/worksmobile_live_flow_test.go
index 81f5b50d..269ffbb0 100644
--- a/backend/internal/service/worksmobile_live_flow_test.go
+++ b/backend/internal/service/worksmobile_live_flow_test.go
@@ -126,6 +126,13 @@ func TestWorksmobileLiveGPDTDCOrgUnitProvisioning(t *testing.T) {
})
}
+func TestWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) {
+ if os.Getenv("WORKSMOBILE_LIVE_BARONGROUP_ORGUNIT_PROVISIONING") != "1" {
+ t.Skip("live Worksmobile Baron Group orgunit provisioning is disabled")
+ }
+ runWorksmobileLiveBaronGroupOrgUnitProvisioning(t)
+}
+
func TestWorksmobileLiveSyncHanmacFamilyOrgUnits(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_SYNC_HANMAC_FAMILY_ORGUNITS") != "1" {
t.Skip("live Worksmobile Hanmac family orgunit sync is disabled")
@@ -548,6 +555,142 @@ func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug stri
}
}
+func runWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) {
+ t.Helper()
+ ctx := context.Background()
+ db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{})
+ require.NoError(t, err)
+
+ tenantRepo := repository.NewTenantRepository(db)
+ userRepo := repository.NewUserRepository(db)
+ userGroupRepo := repository.NewUserGroupRepository(db)
+ tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil)
+ client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
+ ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
+ ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
+ ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
+ PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
+ Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
+ })
+
+ baronGroup, err := tenantService.GetTenantBySlug(ctx, "baron-group")
+ require.NoError(t, err)
+ tenants, err := listWorksmobileLiveTenantScope(db, baronGroup.ID)
+ require.NoError(t, err)
+ domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID")
+ require.True(t, ok, "missing BARONGROUP_DOMAIN_ID")
+ mailDomain := getenvDefault("BARONGROUP_MAIL_DOMAIN", getenvDefault("WORKS_DEFAULT_DOMAIN_BARONGROUP", "brsw.kr"))
+
+ tenantByID := worksmobileTenantByID(append([]domain.Tenant{*baronGroup}, tenants...))
+ targets := worksmobileLiveBaronGroupOrgUnitTargets(t, tenants, tenantByID, *baronGroup, domainID, mailDomain)
+ targetByID := map[string]worksmobileLiveOrgUnitTarget{}
+ for _, target := range targets {
+ targetByID[target.Tenant.ID] = target
+ }
+
+ remoteGroups, err := client.ListGroups(ctx)
+ require.NoError(t, err)
+ remoteByExternalID, duplicateExternalKeys := worksmobileLiveRemoteByExternalID(remoteGroups)
+ require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
+
+ for _, target := range sortWorksmobileLiveTargetsTopologically(targets, tenantByID) {
+ remote, found := remoteByExternalID[target.Tenant.ID]
+ if found && remote.DomainID != target.Payload.DomainID {
+ t.Logf("REKEY conflicting external key slug=%s external=%s worksID=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.ID, remote.DomainID, target.Payload.DomainID)
+ if err := client.ClearOrgUnitExternalKey(ctx, remote.ID, remote.DomainID); err != nil {
+ legacyPatch := WorksmobileOrgUnitPatchPayload{
+ DomainID: remote.DomainID,
+ OrgUnitExternalKey: "legacy-" + remote.ID,
+ }
+ require.NoError(t, client.PatchOrgUnit(ctx, remote.ID, legacyPatch))
+ }
+ time.Sleep(1100 * time.Millisecond)
+ remoteGroups, err = client.ListGroups(ctx)
+ require.NoError(t, err)
+ remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups)
+ require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
+ remote, found = remoteByExternalID[target.Tenant.ID]
+ if found && remote.DomainID != target.Payload.DomainID {
+ require.Failf(t, "external key is attached to a different Worksmobile domain after rekey", "slug=%s external=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.DomainID, target.Payload.DomainID)
+ }
+ }
+ if !found {
+ remote, found = findWorksmobileLiveRemoteByPath(remoteGroups, worksmobileLiveRemoteByID(remoteGroups), target.Payload.DomainID, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID))
+ }
+ if found {
+ t.Logf("PATCH orgunit slug=%s id=%s worksID=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, remote.ID, target.Payload.Email, target.Payload.ParentOrgUnitID)
+ require.NoError(t, patchWorksmobileLiveOrgUnit(ctx, client, remote.ID, target.Payload))
+ } else {
+ t.Logf("CREATE orgunit slug=%s id=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, target.Payload.Email, target.Payload.ParentOrgUnitID)
+ require.NoError(t, client.UpsertOrgUnit(ctx, target.Payload, target.Tenant.Slug))
+ }
+ time.Sleep(1100 * time.Millisecond)
+
+ remoteGroups, err = client.ListGroups(ctx)
+ require.NoError(t, err)
+ remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups)
+ require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
+ }
+
+ remoteGroups, err = client.ListGroups(ctx)
+ require.NoError(t, err)
+ remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups)
+ require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys")
+ remoteByID := worksmobileLiveRemoteByID(remoteGroups)
+ for _, target := range targets {
+ remote, ok := remoteByExternalID[target.Tenant.ID]
+ require.True(t, ok, "missing Worksmobile orgunit after sync: %s", target.Tenant.Slug)
+ require.Equal(t, target.Payload.DomainID, remote.DomainID, "domain mismatch: %s", target.Tenant.Slug)
+ require.Equal(t, target.Tenant.Name, remote.DisplayName, "name mismatch: %s", target.Tenant.Slug)
+ require.Equal(t, worksmobileMailLocalPart(target.Payload.Email), remote.MailLocalPart, "email local-part mismatch: %s", target.Tenant.Slug)
+ require.Equal(t, strings.ToLower(strings.TrimSpace(target.Payload.Email)), strings.ToLower(strings.TrimSpace(remote.Email)), "email mismatch: %s", target.Tenant.Slug)
+ expectedParentID := ""
+ if parentExternalKey := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentExternalKey != "" && parentExternalKey != target.Payload.ParentOrgUnitID {
+ parentRemote, ok := remoteByExternalID[parentExternalKey]
+ require.True(t, ok, "missing Worksmobile parent for %s", target.Tenant.Slug)
+ expectedParentID = parentRemote.ID
+ parentTarget, ok := targetByID[parentExternalKey]
+ require.True(t, ok, "missing Baron parent target for %s", target.Tenant.Slug)
+ require.Equal(t, worksmobileMailLocalPart(parentTarget.Payload.Email), parentRemote.MailLocalPart, "parent email local-part mismatch: %s", target.Tenant.Slug)
+ }
+ require.Equal(t, expectedParentID, remote.ParentID, "parent mismatch: %s", target.Tenant.Slug)
+ require.Equal(t, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID), worksmobileLiveRemotePath(remoteByID, remote), "path mismatch: %s", target.Tenant.Slug)
+ }
+
+ t.Logf("SUMMARY synced=%d domainID=%d", len(targets), domainID)
+}
+
+func worksmobileLiveBaronGroupOrgUnitTargets(t *testing.T, tenants []domain.Tenant, tenantByID map[string]domain.Tenant, root domain.Tenant, domainID int64, mailDomain string) []worksmobileLiveOrgUnitTarget {
+ t.Helper()
+ mailDomain = strings.ToLower(strings.TrimSpace(mailDomain))
+ require.NotEmpty(t, mailDomain, "baron group mail domain is required")
+ targets := make([]worksmobileLiveOrgUnitTarget, 0)
+ seenExternalKeys := map[string]string{}
+ seenEmails := map[string]string{}
+ for index, tenant := range tenants {
+ if !isWorksmobileOrgUnitTenant(tenant, tenantByID) || worksmobileLiveSkipOrgUnitTenant(tenant) {
+ continue
+ }
+ payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, root, root.Config, index+1)
+ require.NoError(t, err, "payload build failed: %s", tenant.Slug)
+ payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
+ payload.DomainID = domainID
+ payload.Email = strings.ToLower(strings.TrimSpace(tenant.Slug)) + "@" + mailDomain
+ require.NotEmpty(t, payload.Email, "orgunit email is required: %s", tenant.Slug)
+ if owner, exists := seenExternalKeys[payload.OrgUnitExternalKey]; exists {
+ require.Failf(t, "duplicate Baron external key", "external=%s owner=%s duplicate=%s", payload.OrgUnitExternalKey, owner, tenant.Slug)
+ }
+ seenExternalKeys[payload.OrgUnitExternalKey] = tenant.Slug
+ normalizedEmail := strings.ToLower(strings.TrimSpace(payload.Email))
+ if owner, exists := seenEmails[normalizedEmail]; exists {
+ require.Failf(t, "duplicate Baron orgunit email", "email=%s owner=%s duplicate=%s", normalizedEmail, owner, tenant.Slug)
+ }
+ seenEmails[normalizedEmail] = tenant.Slug
+ targets = append(targets, worksmobileLiveOrgUnitTarget{Tenant: tenant, Payload: payload})
+ }
+ return targets
+}
+
func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) {
t.Helper()
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1)
diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go
index a85341a2..23120fee 100644
--- a/backend/internal/service/worksmobile_mapper.go
+++ b/backend/internal/service/worksmobile_mapper.go
@@ -72,6 +72,9 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
if err := ValidateWorksmobileExternalKey(tenant.ID); err != nil {
return WorksmobileOrgUnitPayload{}, err
}
+ if displayOrder < 1 {
+ displayOrder = 1
+ }
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return WorksmobileOrgUnitPayload{}, err
diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go
index da0307c1..73ba88c3 100644
--- a/backend/internal/service/worksmobile_mapper_test.go
+++ b/backend/internal/service/worksmobile_mapper_test.go
@@ -56,6 +56,21 @@ func TestBuildWorksmobileOrgUnitPayloadUsesWorksmobileMailDomainForBarongroup(t
require.Equal(t, "jangheon@brsw.kr", payload.Email)
}
+func TestBuildWorksmobileOrgUnitPayloadDefaultsDisplayOrderToOne(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ tenant := domain.Tenant{
+ ID: "11111111-1111-1111-1111-111111111111",
+ Slug: "tech-dev-center",
+ Name: "기술개발센터",
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+
+ payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 0)
+
+ require.NoError(t, err)
+ require.Equal(t, 1, payload.DisplayOrder)
+}
+
func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) {
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID}
diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go
index a01af8b6..3e971466 100644
--- a/backend/internal/service/worksmobile_relay_worker.go
+++ b/backend/internal/service/worksmobile_relay_worker.go
@@ -82,6 +82,9 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
switch job.ResourceType {
case domain.WorksmobileResourceOrgUnit:
+ if job.Action == domain.WorksmobileActionDelete {
+ return w.client.DeleteOrgUnit(ctx, stringValue(job.Payload["worksmobileId"]))
+ }
if job.Action != domain.WorksmobileActionUpsert {
return nil
}
diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go
index 2954bfc7..66b374e4 100644
--- a/backend/internal/service/worksmobile_sync_service.go
+++ b/backend/internal/service/worksmobile_sync_service.go
@@ -24,6 +24,7 @@ type WorksmobileAdminService interface {
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
+ EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
@@ -62,11 +63,14 @@ type WorksmobileComparison struct {
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
+ BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
+ BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
+ BaronParentSlug string `json:"baronParentSlug,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
@@ -82,8 +86,13 @@ type WorksmobileComparisonItem struct {
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
+ BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
+ BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
+ BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
+ WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
+ WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
Status string `json:"status"`
}
@@ -243,16 +252,21 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
+ return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
+}
+
+func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root *domain.Tenant, tenant domain.Tenant, scopeTenants []domain.Tenant) (*domain.WorksmobileOutbox, error) {
+ tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
- *tenant,
- worksmobileDomainClassificationTenant(*tenant, tenantByID),
+ tenant,
+ worksmobileDomainClassificationTenant(tenant, tenantByID),
root.Config,
0,
)
if err != nil {
return nil, err
}
- payload = normalizeWorksmobileOrgUnitParent(payload, *tenant, tenantByID, root.ID)
+ payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
@@ -269,6 +283,62 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
return item, nil
}
+func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
+ root, err := s.hanmacRoot(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ if s.client == nil {
+ return nil, errors.New("worksmobile client is not configured")
+ }
+ scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
+ if err != nil {
+ return nil, err
+ }
+ worksmobileOrgUnitID = strings.TrimSpace(worksmobileOrgUnitID)
+ if worksmobileOrgUnitID == "" {
+ return nil, errors.New("worksmobile orgunit id is required")
+ }
+ groups, err := s.client.ListGroups(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var target *WorksmobileRemoteGroup
+ for i := range groups {
+ if strings.TrimSpace(groups[i].ID) == worksmobileOrgUnitID {
+ target = &groups[i]
+ break
+ }
+ }
+ if target == nil {
+ return nil, errors.New("worksmobile orgunit not found")
+ }
+ tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
+ if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok {
+ return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants)
+ }
+ if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
+ return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted")
+ }
+ item := &domain.WorksmobileOutbox{
+ ResourceType: domain.WorksmobileResourceOrgUnit,
+ ResourceID: worksmobileOrgUnitID,
+ Action: domain.WorksmobileActionDelete,
+ DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
+ Payload: domain.JSONMap{
+ "worksmobileId": worksmobileOrgUnitID,
+ "externalKey": target.ExternalID,
+ "domainId": target.DomainID,
+ "name": target.DisplayName,
+ "email": target.Email,
+ },
+ }
+ if err := s.outboxRepo.Create(ctx, item); err != nil {
+ return nil, err
+ }
+ return item, nil
+}
+
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -585,6 +655,53 @@ func addWorksmobileLocalPart(target map[string]string, email string, owner strin
}
}
+func findWorksmobileOrgUnitTenantByRemoteLocalPart(remote WorksmobileRemoteGroup, localTenants []domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
+ candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
+ for _, tenant := range localTenants {
+ if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
+ continue
+ }
+ if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
+ return tenant, true
+ }
+ }
+ return domain.Tenant{}, false
+}
+
+func isProtectedWorksmobileRemoteOrgUnit(root domain.Tenant, localTenants []domain.Tenant, remote WorksmobileRemoteGroup) bool {
+ if strings.TrimSpace(remote.ParentID) == "" {
+ return true
+ }
+ candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
+ if len(candidates) == 0 {
+ return false
+ }
+ for _, tenant := range localTenants {
+ if tenant.ParentID == nil || *tenant.ParentID != root.ID || tenant.Type != domain.TenantTypeCompany {
+ continue
+ }
+ if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
+ return true
+ }
+ }
+ return false
+}
+
+func worksmobileRemoteGroupLocalPartCandidates(remote WorksmobileRemoteGroup) map[string]bool {
+ result := map[string]bool{}
+ if localPart := normalizeWorksmobileSlugLocalPart(remote.MailLocalPart); localPart != "" {
+ result[localPart] = true
+ }
+ if localPart, err := domain.ExtractNormalizedEmailLocalPart(remote.Email); err == nil && localPart != "" {
+ result[localPart] = true
+ }
+ return result
+}
+
+func normalizeWorksmobileSlugLocalPart(value string) string {
+ return strings.ToLower(strings.TrimSpace(value))
+}
+
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if tenant.Type == domain.TenantTypeOrganization {
return true
@@ -703,6 +820,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
BaronName: user.Name,
BaronEmail: user.Email,
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
+ BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
Status: "missing_in_worksmobile",
}
@@ -796,24 +914,30 @@ func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]dom
return ""
}
+func worksmobileUserPrimaryOrgSlug(user domain.User, localTenants map[string]domain.Tenant) string {
+ tenantID := worksmobileUserPrimaryOrgID(user)
+ if tenantID == "" {
+ return ""
+ }
+ if tenant, ok := localTenants[tenantID]; ok {
+ return strings.TrimSpace(tenant.Slug)
+ }
+ if user.Tenant != nil && user.Tenant.ID == tenantID {
+ return strings.TrimSpace(user.Tenant.Slug)
+ }
+ return ""
+}
+
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
- remoteByMailLocalPart := map[string]WorksmobileRemoteGroup{}
- ambiguousMailLocalParts := map[string]bool{}
+ remoteByID := map[string]WorksmobileRemoteGroup{}
for _, remote := range remoteGroups {
+ if remote.ID != "" {
+ remoteByID[remote.ID] = remote
+ }
if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote
}
- if remote.ExternalID == "" && remote.MailLocalPart != "" {
- if _, exists := remoteByMailLocalPart[remote.MailLocalPart]; exists {
- delete(remoteByMailLocalPart, remote.MailLocalPart)
- ambiguousMailLocalParts[remote.MailLocalPart] = true
- continue
- }
- if !ambiguousMailLocalParts[remote.MailLocalPart] {
- remoteByMailLocalPart[remote.MailLocalPart] = remote
- }
- }
}
tenantByID := worksmobileTenantByID(localTenants)
localByID := map[string]domain.Tenant{}
@@ -827,9 +951,6 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
}
localByID[tenant.ID] = tenant
remote, matched := remoteByExternalID[tenant.ID]
- if !matched {
- remote, matched = remoteByMailLocalPart[worksmobileMailLocalPart(tenant.Slug)]
- }
if matched && !includeMatched {
matchedRemoteIDs[remote.ID] = true
continue
@@ -837,8 +958,10 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
item := WorksmobileComparisonItem{
ResourceType: "GROUP",
BaronID: tenant.ID,
+ BaronSlug: tenant.Slug,
BaronName: tenant.Name,
BaronParentID: worksmobileTenantParentID(tenant),
+ BaronParentSlug: worksmobileTenantParentSlug(tenant, tenantByID),
BaronParentName: worksmobileTenantParentName(tenant, tenantByID),
Status: "missing_in_worksmobile",
}
@@ -852,6 +975,19 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
item.WorksmobileDomainName = remote.DomainName
item.WorksmobileParentID = remote.ParentID
item.WorksmobileParentName = remote.ParentName
+ if parentRemote, ok := remoteByExternalID[item.BaronParentID]; ok {
+ item.BaronParentWorksmobileID = parentRemote.ID
+ item.BaronParentWorksmobileName = parentRemote.DisplayName
+ item.BaronParentWorksmobileEmail = parentRemote.Email
+ }
+ if parentRemote, ok := remoteByID[remote.ParentID]; ok {
+ if item.WorksmobileParentName == "" {
+ item.WorksmobileParentName = parentRemote.DisplayName
+ }
+ item.WorksmobileParentEmail = parentRemote.Email
+ item.WorksmobileParentExternalKey = parentRemote.ExternalID
+ }
+ item = fillWorksmobileParentFromBaronParentMatch(item)
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
@@ -873,6 +1009,14 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
WorksmobileParentName: remote.ParentName,
Status: "missing_external_key",
})
+ if parentRemote, ok := remoteByID[remote.ParentID]; ok {
+ last := &result[len(result)-1]
+ if last.WorksmobileParentName == "" {
+ last.WorksmobileParentName = parentRemote.DisplayName
+ }
+ last.WorksmobileParentEmail = parentRemote.Email
+ last.WorksmobileParentExternalKey = parentRemote.ExternalID
+ }
continue
}
if ignoredLocalByID[remote.ExternalID] {
@@ -891,11 +1035,35 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
WorksmobileParentName: remote.ParentName,
Status: "missing_in_baron",
})
+ if parentRemote, ok := remoteByID[remote.ParentID]; ok {
+ last := &result[len(result)-1]
+ if last.WorksmobileParentName == "" {
+ last.WorksmobileParentName = parentRemote.DisplayName
+ }
+ last.WorksmobileParentEmail = parentRemote.Email
+ last.WorksmobileParentExternalKey = parentRemote.ExternalID
+ }
}
}
return result
}
+func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem {
+ if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID {
+ return item
+ }
+ if item.WorksmobileParentName == "" {
+ item.WorksmobileParentName = item.BaronParentWorksmobileName
+ }
+ if item.WorksmobileParentEmail == "" {
+ item.WorksmobileParentEmail = item.BaronParentWorksmobileEmail
+ }
+ if item.WorksmobileParentExternalKey == "" {
+ item.WorksmobileParentExternalKey = item.BaronParentID
+ }
+ return item
+}
+
func worksmobileTenantByID(tenants []domain.Tenant) map[string]domain.Tenant {
result := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
@@ -918,3 +1086,11 @@ func worksmobileTenantParentName(tenant domain.Tenant, tenantByID map[string]dom
}
return strings.TrimSpace(tenantByID[parentID].Name)
}
+
+func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string {
+ parentID := worksmobileTenantParentID(tenant)
+ if parentID == "" {
+ return ""
+ }
+ return strings.TrimSpace(tenantByID[parentID].Slug)
+}
diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go
index f571a34b..a6d60d1c 100644
--- a/backend/internal/service/worksmobile_sync_service_test.go
+++ b/backend/internal/service/worksmobile_sync_service_test.go
@@ -166,10 +166,10 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup},
[]WorksmobileRemoteGroup{
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
- {ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name},
+ {ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name, Email: "hanmac@hanmaceng.co.kr"},
{ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name},
{ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name},
- {ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name},
+ {ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name, ParentID: "works-hanmac"},
{ID: "works-legacy-user-group", ExternalID: legacyUserGroup.ID, DisplayName: legacyUserGroup.Name},
{ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
},
@@ -181,6 +181,13 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
require.Equal(t, "matched", items[0].Status)
require.Equal(t, organization.ID, items[1].BaronID)
require.Equal(t, "matched", items[1].Status)
+ require.Equal(t, "works-hanmac", items[1].WorksmobileParentID)
+ require.Equal(t, hanmac.Name, items[1].WorksmobileParentName)
+ require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].WorksmobileParentEmail)
+ require.Equal(t, hanmac.ID, items[1].WorksmobileParentExternalKey)
+ require.Equal(t, "works-hanmac", items[1].BaronParentWorksmobileID)
+ require.Equal(t, hanmac.Name, items[1].BaronParentWorksmobileName)
+ require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].BaronParentWorksmobileEmail)
require.Equal(t, "works-orphan", items[2].ExternalKey)
require.Equal(t, "missing_in_baron", items[2].Status)
}
@@ -304,6 +311,381 @@ func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, organizationID, request.OrgUnitExternalKey)
require.Empty(t, request.ParentOrgUnitID)
+ require.Equal(t, 1, request.DisplayOrder)
+}
+
+func TestWorksmobileSyncServiceEnqueuesExternalKeyMissingOrgUnitDelete(t *testing.T) {
+ rootID := "root-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "한맥가족",
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ client := &fakeWorksmobileDirectoryClient{
+ groups: []WorksmobileRemoteGroup{
+ {
+ ID: "works-org-1",
+ DisplayName: "WORKS 전용 조직",
+ DomainID: 1001,
+ ParentID: "works-parent",
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}},
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ client,
+ )
+
+ item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
+ require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"])
+}
+
+func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(t *testing.T) {
+ rootID := "root-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "한맥가족",
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ client := &fakeWorksmobileDirectoryClient{
+ groups: []WorksmobileRemoteGroup{
+ {
+ ID: "works-org-1",
+ ExternalID: "baron-tenant-1",
+ ParentID: "works-parent",
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}},
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ client,
+ )
+
+ item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
+ require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"])
+}
+
+func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) {
+ t.Setenv("GPDTDC_DOMAIN_ID", "1001")
+ rootID := "root-tenant"
+ orgID := "baron-org-1"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "한맥가족",
+ }
+ organization := domain.Tenant{
+ ID: orgID,
+ Slug: "tech-dev-center",
+ Name: "기술개발센터",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &rootID,
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ client := &fakeWorksmobileDirectoryClient{
+ groups: []WorksmobileRemoteGroup{
+ {
+ ID: "works-org-1",
+ ExternalID: "legacy-external-key",
+ DisplayName: "기술개발센터",
+ MailLocalPart: "tech-dev-center",
+ DomainID: 1001,
+ ParentID: "works-parent",
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{
+ tenants: map[string]domain.Tenant{rootID: root, orgID: organization},
+ list: []domain.Tenant{root, organization},
+ },
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ client,
+ )
+
+ item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
+ require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
+ request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
+ require.Equal(t, orgID, request.OrgUnitExternalKey)
+ require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"])
+}
+
+func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) {
+ rootID := "root-tenant"
+ parent := domain.Tenant{
+ ID: "parent-tenant",
+ Name: "삼안",
+ Slug: "saman",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ }
+ child := domain.Tenant{
+ ID: "child-tenant",
+ Name: "업무",
+ Slug: "operations",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &parent.ID,
+ }
+
+ items := compareWorksmobileGroups(
+ []domain.Tenant{
+ {ID: rootID, Name: "한맥가족", Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup},
+ parent,
+ child,
+ },
+ []WorksmobileRemoteGroup{
+ {ID: "works-parent", ExternalID: parent.ID, DisplayName: "삼안", Email: "saman@samaneng.com"},
+ {ID: "works-child", ExternalID: child.ID, DisplayName: "업무", ParentID: "works-parent"},
+ },
+ true,
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, child.ID, items[0].BaronID)
+ require.Equal(t, "works-parent", items[0].WorksmobileParentID)
+ require.Equal(t, "삼안", items[0].WorksmobileParentName)
+ require.Equal(t, "saman@samaneng.com", items[0].WorksmobileParentEmail)
+ require.Equal(t, parent.ID, items[0].WorksmobileParentExternalKey)
+}
+
+func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtectedDeleteGuard(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ rootID := "root-tenant"
+ orgID := "baron-operations"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "한맥가족",
+ }
+ samanID := "saman-tenant"
+ saman := domain.Tenant{
+ ID: samanID,
+ Slug: "saman",
+ Name: "삼안",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ organization := domain.Tenant{
+ ID: orgID,
+ Slug: "operations",
+ Name: "업무",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &samanID,
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ client := &fakeWorksmobileDirectoryClient{
+ groups: []WorksmobileRemoteGroup{
+ {
+ ID: "works-operations",
+ ExternalID: "legacy-operations-id",
+ DisplayName: "업무팀",
+ Email: "operations@samaneng.com",
+ MailLocalPart: "operations",
+ DomainID: 1001,
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{
+ tenants: map[string]domain.Tenant{rootID: root, samanID: saman, orgID: organization},
+ list: []domain.Tenant{root, saman, organization},
+ },
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ client,
+ )
+
+ item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-operations")
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
+ require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
+ request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
+ require.Equal(t, orgID, request.OrgUnitExternalKey)
+ require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"])
+}
+
+func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) {
+ rootID := "root-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "한맥가족",
+ }
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ client := &fakeWorksmobileDirectoryClient{
+ groups: []WorksmobileRemoteGroup{
+ {
+ ID: "works-root",
+ DisplayName: "한맥기술",
+ },
+ },
+ }
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ client,
+ )
+
+ item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-root")
+
+ require.Nil(t, item)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "protected worksmobile domain root")
+ require.Empty(t, outboxRepo.created)
+}
+
+func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("HANMAC_DOMAIN_ID", "1002")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
+ rootID := "root-tenant"
+ root := domain.Tenant{
+ ID: rootID,
+ Slug: HanmacFamilyTenantSlug,
+ Name: "한맥가족",
+ }
+ tests := []struct {
+ name string
+ company domain.Tenant
+ organization domain.Tenant
+ wantDomainID int64
+ wantEmail string
+ }{
+ {
+ name: "saman",
+ company: domain.Tenant{
+ ID: "company-saman",
+ Slug: "saman",
+ Name: "삼안",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ },
+ organization: domain.Tenant{
+ ID: "org-saman-planning",
+ Slug: "saman-planning",
+ Name: "삼안 기획팀",
+ Type: domain.TenantTypeOrganization,
+ },
+ wantDomainID: 1001,
+ wantEmail: "saman-planning@samaneng.com",
+ },
+ {
+ name: "hanmac",
+ company: domain.Tenant{
+ ID: "company-hanmac",
+ Slug: "hanmac",
+ Name: "한맥기술",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
+ },
+ organization: domain.Tenant{
+ ID: "org-hanmac-planning",
+ Slug: "hanmac-planning",
+ Name: "한맥 기획팀",
+ Type: domain.TenantTypeOrganization,
+ },
+ wantDomainID: 1002,
+ wantEmail: "hanmac-planning@hanmaceng.co.kr",
+ },
+ {
+ name: "gpdtdc",
+ company: domain.Tenant{
+ ID: "company-gpdtdc",
+ Slug: "gpdtdc",
+ Name: "총괄기획&기술개발센터",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ },
+ organization: domain.Tenant{
+ ID: "org-gpdtdc-planning",
+ Slug: "gpdtdc-planning",
+ Name: "총괄 기획팀",
+ Type: domain.TenantTypeOrganization,
+ },
+ wantDomainID: 1003,
+ wantEmail: "gpdtdc-planning@baroncs.co.kr",
+ },
+ {
+ name: "baron-group",
+ company: domain.Tenant{
+ ID: "company-barongroup",
+ Slug: "baron-group",
+ Name: "바론그룹",
+ Type: domain.TenantTypeCompany,
+ ParentID: &rootID,
+ },
+ organization: domain.Tenant{
+ ID: "org-baron-planning",
+ Slug: "baron-planning",
+ Name: "바론 기획팀",
+ Type: domain.TenantTypeOrganization,
+ },
+ wantDomainID: 1004,
+ wantEmail: "baron-planning@brsw.kr",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ organization := tt.organization
+ organization.ParentID = &tt.company.ID
+ outboxRepo := &fakeWorksmobileOutboxRepo{}
+ service := NewWorksmobileSyncService(
+ &fakeWorksmobileTenantService{
+ tenants: map[string]domain.Tenant{
+ rootID: root,
+ tt.company.ID: tt.company,
+ organization.ID: organization,
+ },
+ list: []domain.Tenant{root, tt.company, organization},
+ },
+ &fakeWorksmobileUserRepo{},
+ outboxRepo,
+ nil,
+ )
+
+ item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID)
+
+ require.NoError(t, err)
+ require.NotNil(t, item)
+ require.Len(t, outboxRepo.created, 1)
+ request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
+ require.Equal(t, organization.ID, request.OrgUnitExternalKey)
+ require.Equal(t, tt.wantDomainID, request.DomainID)
+ require.Equal(t, tt.wantEmail, request.Email)
+ require.Empty(t, request.ParentOrgUnitID)
+ })
+ }
}
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {
diff --git a/common/config/vite.base.ts b/common/config/vite.base.ts
index e500ff97..5169972b 100644
--- a/common/config/vite.base.ts
+++ b/common/config/vite.base.ts
@@ -1,9 +1,15 @@
import { createRequire } from "node:module";
import path from "node:path";
+import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
-import { defineConfig, type UserConfig } from "vite";
+import { type UserConfig, defineConfig } from "vite";
const require = createRequire(import.meta.url);
+const commonWorkspaceDir = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+);
+const appWorkspaceDir = path.resolve(process.cwd());
const reactPackageDir = path.dirname(require.resolve("react/package.json"));
const reactDomPackageDir = path.dirname(
require.resolve("react-dom/package.json"),
@@ -23,6 +29,11 @@ export const commonViteConfig: UserConfig = {
build: {
emptyOutDir: true,
},
+ server: {
+ fs: {
+ allow: [appWorkspaceDir, commonWorkspaceDir, "/workspace/common"],
+ },
+ },
};
export function hostFromUrl(value: string | undefined) {
@@ -37,7 +48,7 @@ export function hostFromUrl(value: string | undefined) {
export function getAllowedHosts(
defaultHosts: string[],
envUrl?: string,
- envAllowedHosts?: string
+ envAllowedHosts?: string,
) {
return Array.from(
new Set(
@@ -48,8 +59,8 @@ export function getAllowedHosts(
.split(",")
.map((host) => host.trim())
.filter(Boolean),
- ].filter((host): host is string => Boolean(host))
- )
+ ].filter((host): host is string => Boolean(host)),
+ ),
);
}
diff --git a/common/shell/layout.ts b/common/shell/layout.ts
index 52328239..96b002d9 100644
--- a/common/shell/layout.ts
+++ b/common/shell/layout.ts
@@ -1,9 +1,9 @@
export const shellLayoutClasses = {
- root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
+ root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
aside:
- "flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur",
+ "sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
asideStatic:
- "border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur",
+ "sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
brandSection:
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
@@ -24,7 +24,8 @@ export const shellLayoutClasses = {
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
logoutButton:
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
- header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
+ header:
+ "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
headerElevated:
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh
index 0b01885c..f6e7b03e 100644
--- a/devfront/scripts/runtime-mode.sh
+++ b/devfront/scripts/runtime-mode.sh
@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
fi
ensure_frontend_dependencies() {
- # If common workspace exists, manage dependencies from there
- if [ -d /common ] && [ -f /common/package.json ]; then
- WORKSPACE_DIR="/common"
- LOCK_FILE="/common/pnpm-lock.yaml"
+ APP_WORKSPACE_FILTER="../devfront"
+
+ # If common workspace exists, manage dependencies from the real workspace tree.
+ if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
+ WORKSPACE_DIR="/workspace/common"
+ LOCK_FILE="/workspace/common/pnpm-lock.yaml"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies..."
- if [ "$WORKSPACE_DIR" = "/common" ]; then
-
- (cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
+ if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
+ (cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi
diff --git a/devfront/tailwind.config.ts b/devfront/tailwind.config.ts
index 3f8ed216..401a9bd8 100644
--- a/devfront/tailwind.config.ts
+++ b/devfront/tailwind.config.ts
@@ -6,7 +6,8 @@ const config: Config = {
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
- "../common/**/*.{ts,tsx,css}",
+ "../common/core/**/*.{ts,tsx}",
+ "../common/shell/**/*.{ts,tsx}",
],
};
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 3f264d09..a53421f4 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -61,10 +61,13 @@ services:
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
- - ./adminfront:/app
+ - ./adminfront:/workspace/adminfront
- ./common:/common
+ - ./common:/workspace/common
+ - /workspace/common/node_modules
- ./locales:/locales
- - /app/node_modules
+ - ./locales:/workspace/locales
+ - /workspace/adminfront/node_modules
networks:
- baron_net
@@ -82,10 +85,13 @@ services:
ports:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
- - ./devfront:/app
+ - ./devfront:/workspace/devfront
- ./common:/common
+ - ./common:/workspace/common
+ - /workspace/common/node_modules
- ./locales:/locales
- - /app/node_modules
+ - ./locales:/workspace/locales
+ - /workspace/devfront/node_modules
networks:
- baron_net
@@ -103,10 +109,13 @@ services:
ports:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
- - ./orgfront:/app
+ - ./orgfront:/workspace/orgfront
- ./common:/common
+ - ./common:/workspace/common
+ - /workspace/common/node_modules
- ./locales:/locales
- - /app/node_modules
+ - ./locales:/workspace/locales
+ - /workspace/orgfront/node_modules
networks:
- baron_net
diff --git a/orgfront/package.json b/orgfront/package.json
index 05634fef..1c5395a4 100644
--- a/orgfront/package.json
+++ b/orgfront/package.json
@@ -9,7 +9,7 @@
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
- "build:org-context-chart": "npm run build:org-context-chart:full && npm run build:org-context-chart:min",
+ "build:org-context-chart": "node scripts/build-org-context-chart.mjs",
"build:org-context-chart:full": "vite build --config vite.org-context-chart.config.ts",
"build:org-context-chart:min": "ORG_CONTEXT_CHART_MINIFY=true vite build --config vite.org-context-chart.config.ts",
"lint": "biome check .",
diff --git a/orgfront/scripts/build-org-context-chart.mjs b/orgfront/scripts/build-org-context-chart.mjs
new file mode 100644
index 00000000..43d64661
--- /dev/null
+++ b/orgfront/scripts/build-org-context-chart.mjs
@@ -0,0 +1,29 @@
+import { spawnSync } from "node:child_process";
+
+const buildId = createBuildId();
+const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
+const env = {
+ ...process.env,
+ ORG_CONTEXT_CHART_BUILD_ID: buildId,
+};
+
+for (const script of [
+ "build:org-context-chart:full",
+ "build:org-context-chart:min",
+]) {
+ const result = spawnSync(npmCommand, ["run", script], {
+ env,
+ stdio: "inherit",
+ });
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+ }
+}
+
+function createBuildId() {
+ const now = new Date();
+ const year = String(now.getFullYear()).slice(-2);
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
+ return `${year}${month}${random}`;
+}
diff --git a/orgfront/scripts/runtime-mode.sh b/orgfront/scripts/runtime-mode.sh
index 6c2cf018..ca677fa6 100644
--- a/orgfront/scripts/runtime-mode.sh
+++ b/orgfront/scripts/runtime-mode.sh
@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
fi
ensure_frontend_dependencies() {
- # If common workspace exists, manage dependencies from there
- if [ -d /common ] && [ -f /common/package.json ]; then
- WORKSPACE_DIR="/common"
- LOCK_FILE="/common/pnpm-lock.yaml"
+ APP_WORKSPACE_FILTER="../orgfront"
+
+ # If common workspace exists, manage dependencies from the real workspace tree.
+ if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
+ WORKSPACE_DIR="/workspace/common"
+ LOCK_FILE="/workspace/common/pnpm-lock.yaml"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies..."
- if [ "$WORKSPACE_DIR" = "/common" ]; then
-
- (cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
+ if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
+ (cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi
diff --git a/orgfront/src/sdk/org-context-chart/index.ts b/orgfront/src/sdk/org-context-chart/index.ts
index a4efe70f..32e606f2 100644
--- a/orgfront/src/sdk/org-context-chart/index.ts
+++ b/orgfront/src/sdk/org-context-chart/index.ts
@@ -85,19 +85,101 @@ export type OrgPickerSelection = {
type: "tenant" | "user";
};
+export type OrgPickerVariant = "default" | "orgfront";
+
export type OrgPickerOptions = {
mode?: "single" | "multiple";
selectable?: "tenant" | "user" | "both";
includeDescendants?: boolean;
+ injectStyles?: boolean;
+ showDescendantToggle?: boolean;
+ variant?: OrgPickerVariant;
+ onCancel?: () => void;
onChange?: (selection: OrgPickerSelection[]) => void;
+ onConfirm?: (selection: OrgPickerSelection[]) => void;
};
export type OrgPickerController = {
+ cancel: () => void;
+ confirm: () => void;
destroy: () => void;
getSelection: () => OrgPickerSelection[];
};
const API_PATH = "/api/v1/integrations/org-context";
+const DEFAULT_STYLE_ID = "baron-org-context-chart-default-style";
+const DEFAULT_STYLE = `
+.baron-org-chart,.baron-org-picker{box-sizing:border-box;color:#0f172a;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}
+.baron-org-chart *,.baron-org-picker *{box-sizing:border-box}
+.baron-org-chart__tree{display:flex;min-width:100%;gap:24px;overflow:auto;padding:16px}
+.baron-org-chart__node{min-width:220px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 10px 30px rgba(15,23,42,.08)}
+.baron-org-chart__title{margin:0;padding:10px 12px;border-bottom:1px solid #e5e7eb;background:#f8fafc;font-size:14px;font-weight:700}
+.baron-org-chart__meta{margin:0;padding:8px 12px;color:#64748b;font-size:12px}
+.baron-org-chart__members{margin:0;padding:0 12px 12px 28px;color:#334155;font-size:12px}
+.baron-org-chart__children{display:flex;gap:16px;margin:12px;padding:12px 0 0 16px;border-left:1px solid #d8dee9}
+.baron-org-picker{width:100%;max-width:520px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 12px 34px rgba(15,23,42,.1);overflow:hidden}
+.baron-org-picker__toolbar{display:flex;flex-direction:column;gap:10px;padding:12px;border-bottom:1px solid #e5e7eb;background:#f8fafc}
+.baron-org-picker__search-wrap{position:relative}
+.baron-org-picker__search-icon{pointer-events:none;position:absolute;left:12px;top:50%;width:16px;height:16px;transform:translateY(-50%);color:#64748b}
+.baron-org-picker__search-icon::before{content:"";position:absolute;left:2px;top:2px;width:8px;height:8px;border:2px solid currentColor;border-radius:999px}
+.baron-org-picker__search-icon::after{content:"";position:absolute;left:10px;top:11px;width:6px;height:2px;background:currentColor;border-radius:999px;transform:rotate(45deg);transform-origin:left center}
+.baron-org-picker__search{width:100%;height:38px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;padding:0 10px;color:#0f172a;font:inherit;outline:none}
+.baron-org-picker__search:focus{border-color:#24449c;box-shadow:0 0 0 3px rgba(36,68,156,.18)}
+.baron-org-picker__controls{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#475569;font-size:12px}
+.baron-org-picker__descendants{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
+.baron-org-picker__summary{color:#64748b}
+.baron-org-picker__clear{border:0;background:transparent;color:#24449c;cursor:pointer;font:inherit;font-weight:700;padding:2px 0}
+.baron-org-picker__clear:hover{text-decoration:underline}
+.baron-org-picker__list,.baron-org-picker__children{list-style:none;margin:0;padding:0}
+.baron-org-picker__list{max-height:420px;overflow:auto;padding:8px}
+.baron-org-picker__item{margin:0}
+.baron-org-picker__children{margin-left:8px;border-left:1px solid #e5e7eb}
+.baron-org-picker__row{display:flex;min-height:32px;align-items:center;gap:8px;border-radius:6px;padding:4px 8px;color:#0f172a;cursor:pointer}
+.baron-org-picker__row:hover{background:#f1f5f9}
+.baron-org-picker__row--selected{background:#e8eefc;color:#18327a}
+.baron-org-picker__row--member{color:#334155;font-size:13px}
+.baron-org-picker__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.baron-org-picker__label-primary,.baron-org-picker__label-secondary{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.baron-org-picker__label-primary{font-weight:600;line-height:20px}
+.baron-org-picker__label-secondary{color:#64748b;font-size:12px;line-height:20px}
+.baron-org-picker input[type="checkbox"],.baron-org-picker input[type="radio"]{accent-color:#24449c}
+.baron-org-picker__empty{padding:28px 12px;color:#64748b;text-align:center}
+.baron-org-picker__toggle,.baron-org-picker__toggle-placeholder{display:grid;width:24px;height:24px;flex:0 0 24px;place-items:center;border:0;border-radius:4px;background:transparent;color:#64748b;font:inherit;line-height:1}
+.baron-org-picker__toggle{cursor:pointer}
+.baron-org-picker__toggle:hover{background:#e2e8f0}
+.baron-org-picker__chevron{position:relative;display:block;width:16px;height:16px;color:currentColor}
+.baron-org-picker__chevron::before{content:"";position:absolute;width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor}
+.baron-org-picker__chevron--open::before{left:4px;top:3px;transform:rotate(45deg)}
+.baron-org-picker__chevron--closed::before{left:3px;top:4px;transform:rotate(-45deg)}
+.baron-org-picker__select{min-width:0;flex:1;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;outline:none;padding:0 4px}
+.baron-org-picker__select:focus-visible{box-shadow:0 0 0 2px #3a98e5}
+.baron-org-picker__footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e5e7eb;background:#fff;padding:8px 12px}
+.baron-org-picker__actions{display:flex;align-items:center;gap:8px}
+.baron-org-picker__button{display:inline-flex;height:36px;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border-radius:6px;border:1px solid #e5e7eb;background:#fff;color:#0f172a;padding:0 12px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
+.baron-org-picker__button:hover{background:#f4b840;color:#0f172a}
+.baron-org-picker__button--primary{border-color:#3a98e5;background:#3a98e5;color:#fff;box-shadow:0 1px 2px rgba(15,23,42,.12)}
+.baron-org-picker__button--primary:hover{background:#2588d8;color:#fff}
+.baron-org-picker__button:disabled{cursor:not-allowed;opacity:.5}
+.baron-org-picker--orgfront{--boc-background:hsl(var(--background,0 0% 98%));--boc-foreground:hsl(var(--foreground,223 25% 12%));--boc-primary:hsl(var(--primary,209 79% 52%));--boc-secondary:hsl(var(--secondary,220 17% 94%));--boc-muted-foreground:hsl(var(--muted-foreground,223 15% 45%));--boc-accent:hsl(var(--accent,40 96% 62%));--boc-accent-foreground:hsl(var(--accent-foreground,223 25% 12%));--boc-border:hsl(var(--border,220 17% 90%));--boc-input:hsl(var(--input,220 17% 90%));--boc-ring:hsl(var(--ring,209 79% 52%));display:flex;height:100%;min-height:320px;max-width:none;flex-direction:column;border:0;border-radius:0;background:var(--boc-background);box-shadow:none;color:var(--boc-foreground);overflow:hidden}
+.baron-org-picker--orgfront .baron-org-picker__toolbar{display:block;border-bottom:1px solid var(--boc-border);background:var(--boc-background);padding:8px}
+.baron-org-picker--orgfront .baron-org-picker__toolbar-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}
+.baron-org-picker--orgfront .baron-org-picker__search{height:36px;border-color:var(--boc-input);border-radius:6px;background:var(--boc-background);padding:0 12px 0 36px;font-size:14px}
+.baron-org-picker--orgfront .baron-org-picker__search:focus{border-color:var(--boc-input);box-shadow:0 0 0 2px var(--boc-ring)}
+.baron-org-picker--orgfront .baron-org-picker__controls{height:36px;font-size:14px}
+.baron-org-picker--orgfront .baron-org-picker__descendants{height:36px;font-size:14px}
+.baron-org-picker--orgfront .baron-org-picker__list{min-height:0;flex:1;max-height:none;overflow:auto;padding:12px}
+.baron-org-picker--orgfront .baron-org-picker__children{margin-left:16px;border-left:0}
+.baron-org-picker--orgfront .baron-org-picker__row{min-height:28px;gap:6px;border-radius:4px;padding:2px 6px 2px 4px;transition:background-color .15s,color .15s,box-shadow .15s}
+.baron-org-picker--orgfront .baron-org-picker__row:hover{background:color-mix(in srgb,var(--boc-secondary) 50%,transparent)}
+.baron-org-picker--orgfront .baron-org-picker__row--selected{background:color-mix(in srgb,var(--boc-primary) 15%,transparent);box-shadow:0 0 0 2px color-mix(in srgb,var(--boc-primary) 60%,transparent);color:var(--boc-foreground)}
+.baron-org-picker--orgfront .baron-org-picker__row--member{font-size:14px;color:var(--boc-foreground)}
+.baron-org-picker--orgfront .baron-org-picker__footer{border-top-color:var(--boc-border);background:var(--boc-background)}
+.baron-org-picker--orgfront .baron-org-picker__summary{font-size:14px;color:var(--boc-muted-foreground)}
+.baron-org-picker--orgfront .baron-org-picker__button{border-color:var(--boc-input);background:var(--boc-background);color:var(--boc-foreground)}
+.baron-org-picker--orgfront .baron-org-picker__button:hover{background:var(--boc-accent);color:var(--boc-accent-foreground)}
+.baron-org-picker--orgfront .baron-org-picker__button--primary{border-color:var(--boc-primary);background:var(--boc-primary);color:#fff}
+.baron-org-picker--orgfront .baron-org-picker__empty{margin:12px;min-height:160px;border:1px dashed var(--boc-border);border-radius:6px;background:var(--boc-background);display:grid;place-items:center}
+`;
export function createOrgContextClient(options: OrgContextClientOptions) {
const fetcher = options.fetch ?? globalThis.fetch;
@@ -202,6 +284,7 @@ export function renderOrgChart(
container: HTMLElement,
model: OrgChartModel,
): { destroy: () => void } {
+ ensureDefaultStyles();
container.replaceChildren();
container.classList.add("baron-org-chart");
const root = document.createElement("div");
@@ -221,13 +304,24 @@ export function renderOrgPicker(
model: OrgChartModel,
options: OrgPickerOptions = {},
): OrgPickerController {
+ if (options.injectStyles !== false) {
+ ensureDefaultStyles();
+ }
const mode = options.mode ?? "single";
const selectable = options.selectable ?? "tenant";
- const includeDescendants = options.includeDescendants ?? false;
+ const variant = options.variant ?? "default";
+ const isOrgfront = variant === "orgfront";
+ let includeDescendants =
+ options.includeDescendants ?? (isOrgfront && mode === "multiple");
+ let searchQuery = "";
const selected = new Map();
+ const expanded = new Set(model.nodes.map((node) => node.id));
+ const showDescendantToggle = options.showDescendantToggle ?? true;
+
+ const currentSelection = () => Array.from(selected.values());
const emitChange = () => {
- const selection = Array.from(selected.values());
+ const selection = currentSelection();
options.onChange?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-change", {
@@ -237,6 +331,26 @@ export function renderOrgPicker(
);
};
+ const emitConfirm = () => {
+ const selection = currentSelection();
+ options.onConfirm?.(selection);
+ container.dispatchEvent(
+ new CustomEvent("baron-org-picker-confirm", {
+ bubbles: true,
+ detail: { selection },
+ }),
+ );
+ };
+
+ const emitCancel = () => {
+ options.onCancel?.();
+ container.dispatchEvent(
+ new CustomEvent("baron-org-picker-cancel", {
+ bubbles: true,
+ }),
+ );
+ };
+
const toggleSelection = (
selection: OrgPickerSelection,
checked: boolean,
@@ -270,17 +384,82 @@ export function renderOrgPicker(
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
const item = document.createElement("li");
item.className = "baron-org-picker__item";
-
- const row = document.createElement("label");
- row.className = "baron-org-picker__row";
- row.style.paddingLeft = `${node.depth * 16}px`;
+ const hasChildren = node.children.length > 0;
const tenantSelection: OrgPickerSelection = {
id: node.id,
name: node.name,
type: "tenant",
};
- if (selectable === "tenant" || selectable === "both") {
+ const row = document.createElement(isOrgfront ? "div" : "label");
+ row.className = "baron-org-picker__row";
+ if (selected.has(selectionKey(tenantSelection))) {
+ row.classList.add("baron-org-picker__row--selected");
+ }
+ row.style.paddingLeft = `${node.depth * 16}px`;
+
+ if (isOrgfront) {
+ row.append(createExpandToggle(node, hasChildren));
+ appendOrgfrontSelectionControl(row, node, tenantSelection);
+ } else {
+ if (selectable === "tenant" || selectable === "both") {
+ row.append(
+ createPickerInput({
+ mode,
+ selection: tenantSelection,
+ selected,
+ onToggle: (checked) =>
+ toggleSelection(
+ tenantSelection,
+ checked,
+ collectDescendantSelections(node, selectable),
+ ),
+ }),
+ );
+ }
+ row.append(createLabelText(node.name, node.type));
+ }
+ item.append(row);
+
+ if (selectable === "user" || selectable === "both") {
+ for (const member of node.members) {
+ item.append(
+ renderMemberPickerRow(
+ member,
+ node,
+ mode,
+ selected,
+ (value) =>
+ toggleSelection(value, !selected.has(selectionKey(value)), []),
+ isOrgfront,
+ ),
+ );
+ }
+ }
+
+ if (hasChildren && (!isOrgfront || expanded.has(node.id))) {
+ const children = document.createElement("ul");
+ children.className = "baron-org-picker__children";
+ for (const child of node.children) {
+ children.append(renderPickerNode(child));
+ }
+ item.append(children);
+ }
+ return item;
+ };
+
+ const appendOrgfrontSelectionControl = (
+ row: HTMLElement,
+ node: OrgChartNode,
+ tenantSelection: OrgPickerSelection,
+ ) => {
+ const canSelect = selectable === "tenant" || selectable === "both";
+ if (!canSelect) {
+ row.append(createOrgfrontLabelText(node.name));
+ return;
+ }
+
+ if (mode === "multiple") {
row.append(
createPickerInput({
mode,
@@ -294,50 +473,231 @@ export function renderOrgPicker(
),
}),
);
- }
- row.append(createLabelText(node.name, node.type));
- item.append(row);
-
- if (selectable === "user" || selectable === "both") {
- for (const member of node.members) {
- item.append(
- renderMemberPickerRow(member, node, mode, selected, (value) =>
- toggleSelection(value, !selected.has(selectionKey(value)), []),
- ),
- );
- }
+ row.append(createOrgfrontLabelText(node.name));
+ return;
}
- if (node.children.length > 0) {
- const children = document.createElement("ul");
- children.className = "baron-org-picker__children";
- for (const child of node.children) {
- children.append(renderPickerNode(child));
- }
- item.append(children);
+ const button = document.createElement("button");
+ button.className = "baron-org-picker__select";
+ button.dataset.baronOrgPickerValue = selectionKey(tenantSelection);
+ button.type = "button";
+ button.ariaPressed = String(selected.has(selectionKey(tenantSelection)));
+ button.addEventListener("click", () =>
+ toggleSelection(
+ tenantSelection,
+ true,
+ collectDescendantSelections(node, selectable),
+ ),
+ );
+ button.append(createOrgfrontLabelText(node.name));
+ row.append(button);
+ };
+
+ const createExpandToggle = (node: OrgChartNode, hasChildren: boolean) => {
+ if (!hasChildren) {
+ const placeholder = document.createElement("span");
+ placeholder.className = "baron-org-picker__toggle-placeholder";
+ placeholder.ariaHidden = "true";
+ return placeholder;
}
- return item;
+
+ const toggle = document.createElement("button");
+ toggle.className = "baron-org-picker__toggle";
+ toggle.dataset.baronOrgPickerToggle = selectionKey({
+ id: node.id,
+ name: node.name,
+ type: "tenant",
+ });
+ toggle.type = "button";
+ const chevron = document.createElement("span");
+ chevron.className = expanded.has(node.id)
+ ? "baron-org-picker__chevron baron-org-picker__chevron--open"
+ : "baron-org-picker__chevron baron-org-picker__chevron--closed";
+ chevron.dataset.baronOrgPickerChevron = "true";
+ chevron.ariaHidden = "true";
+ toggle.append(chevron);
+ toggle.ariaLabel = `${node.name} ${expanded.has(node.id) ? "접기" : "펼치기"}`;
+ toggle.addEventListener("click", () => {
+ if (expanded.has(node.id)) {
+ expanded.delete(node.id);
+ } else {
+ expanded.add(node.id);
+ }
+ rerender();
+ });
+ return toggle;
};
const rerender = () => {
container.replaceChildren();
container.classList.add("baron-org-picker");
+ container.classList.toggle("baron-org-picker--orgfront", isOrgfront);
+ container.append(renderPickerToolbar());
+
+ const visibleRoot = filterOrgChartNode(model.root, searchQuery, selectable);
+ if (!visibleRoot) {
+ const empty = document.createElement("div");
+ empty.className = "baron-org-picker__empty";
+ empty.textContent = isOrgfront
+ ? "검색 결과가 없습니다."
+ : "No matching organization or member.";
+ container.append(empty);
+ if (isOrgfront) {
+ container.append(renderPickerFooter());
+ }
+ return;
+ }
+
const list = document.createElement("ul");
list.className = "baron-org-picker__list";
- list.append(renderPickerNode(model.root));
+ list.append(renderPickerNode(visibleRoot));
container.append(list);
+ if (isOrgfront) {
+ container.append(renderPickerFooter());
+ }
+ };
+
+ const renderPickerToolbar = () => {
+ const toolbar = document.createElement("div");
+ toolbar.className = "baron-org-picker__toolbar";
+ const toolbarContent = isOrgfront ? document.createElement("div") : toolbar;
+ if (isOrgfront) {
+ toolbarContent.className = "baron-org-picker__toolbar-grid";
+ toolbar.append(toolbarContent);
+ }
+
+ const searchWrap = document.createElement("div");
+ searchWrap.className = "baron-org-picker__search-wrap";
+ if (isOrgfront) {
+ const searchIcon = document.createElement("span");
+ searchIcon.className = "baron-org-picker__search-icon";
+ searchIcon.dataset.baronOrgPickerSearchIcon = "true";
+ searchIcon.ariaHidden = "true";
+ searchWrap.append(searchIcon);
+ }
+ const search = document.createElement("input");
+ search.className = "baron-org-picker__search";
+ search.dataset.baronOrgPickerSearch = "true";
+ search.placeholder = isOrgfront
+ ? "ID, 이름, 이메일, 메타데이터"
+ : "Search organization or member";
+ search.type = "search";
+ search.value = searchQuery;
+ search.addEventListener("input", () => {
+ searchQuery = search.value;
+ rerender();
+ const nextSearch = container.querySelector(
+ "[data-baron-org-picker-search]",
+ );
+ nextSearch?.focus();
+ nextSearch?.setSelectionRange(searchQuery.length, searchQuery.length);
+ });
+ searchWrap.append(search);
+ toolbarContent.append(searchWrap);
+
+ const controls = document.createElement("div");
+ controls.className = "baron-org-picker__controls";
+ if (mode === "multiple" && selectable !== "user" && showDescendantToggle) {
+ const descendantsLabel = document.createElement("label");
+ descendantsLabel.className = "baron-org-picker__descendants";
+ const descendants = document.createElement("input");
+ descendants.dataset.baronOrgPickerDescendants = "true";
+ descendants.type = "checkbox";
+ descendants.checked = includeDescendants;
+ const updateDescendantSelection = () => {
+ includeDescendants = descendants.checked;
+ rerender();
+ };
+ descendants.addEventListener("change", updateDescendantSelection);
+ descendants.addEventListener("click", updateDescendantSelection);
+ descendantsLabel.append(
+ descendants,
+ isOrgfront ? "하위 선택" : "Include descendants",
+ );
+ controls.append(descendantsLabel);
+ } else if (!isOrgfront) {
+ controls.append(document.createElement("span"));
+ }
+
+ if (!isOrgfront) {
+ const summary = document.createElement("span");
+ summary.className = "baron-org-picker__summary";
+ summary.dataset.baronOrgPickerSummary = "true";
+ summary.textContent = `${selected.size} selected`;
+ controls.append(summary);
+
+ if (selected.size > 0) {
+ const clear = document.createElement("button");
+ clear.className = "baron-org-picker__clear";
+ clear.type = "button";
+ clear.textContent = "Clear";
+ clear.addEventListener("click", () => {
+ selected.clear();
+ emitChange();
+ rerender();
+ });
+ controls.append(clear);
+ }
+ }
+
+ toolbarContent.append(controls);
+ return toolbar;
+ };
+
+ const renderPickerFooter = () => {
+ const footer = document.createElement("footer");
+ footer.className = "baron-org-picker__footer";
+ footer.dataset.baronOrgPickerFooter = "true";
+
+ const summary = document.createElement("div");
+ summary.className = "baron-org-picker__summary";
+ summary.dataset.baronOrgPickerSummary = "true";
+ summary.textContent =
+ selected.size > 0
+ ? `${selected.size}개 항목 선택됨`
+ : "선택된 항목이 없습니다.";
+ footer.append(summary);
+
+ const actions = document.createElement("div");
+ actions.className = "baron-org-picker__actions";
+
+ const cancel = document.createElement("button");
+ cancel.className = "baron-org-picker__button";
+ cancel.dataset.baronOrgPickerCancel = "true";
+ cancel.type = "button";
+ cancel.textContent = "취소";
+ cancel.addEventListener("click", emitCancel);
+ actions.append(cancel);
+
+ const confirm = document.createElement("button");
+ confirm.className =
+ "baron-org-picker__button baron-org-picker__button--primary";
+ confirm.dataset.baronOrgPickerConfirm = "true";
+ confirm.disabled = selected.size === 0;
+ confirm.type = "button";
+ confirm.textContent = "선택 완료";
+ confirm.addEventListener("click", emitConfirm);
+ actions.append(confirm);
+
+ footer.append(actions);
+ return footer;
};
rerender();
return {
+ cancel: emitCancel,
+ confirm: emitConfirm,
destroy() {
container.replaceChildren();
- container.classList.remove("baron-org-picker");
+ container.classList.remove(
+ "baron-org-picker",
+ "baron-org-picker--orgfront",
+ );
selected.clear();
},
getSelection() {
- return Array.from(selected.values());
+ return currentSelection();
},
};
}
@@ -356,6 +716,16 @@ function normalizeBaseUrl(baseUrl: string) {
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}
+function ensureDefaultStyles() {
+ if (typeof document === "undefined") return;
+ if (document.getElementById(DEFAULT_STYLE_ID)) return;
+ const style = document.createElement("style");
+ style.id = DEFAULT_STYLE_ID;
+ style.dataset.baronOrgContextChartStyle = "default";
+ style.textContent = DEFAULT_STYLE;
+ document.head.append(style);
+}
+
function renderChartNode(node: OrgChartNode): HTMLElement {
const item = document.createElement("section");
item.className = "baron-org-chart__node";
@@ -401,15 +771,35 @@ function renderMemberPickerRow(
mode: "single" | "multiple",
selected: Map,
onSelect: (selection: OrgPickerSelection) => void,
+ isOrgfront = false,
) {
const selection: OrgPickerSelection = {
id: member.id || `${node.id}:${member.email}`,
name: member.name,
type: "user",
};
- const row = document.createElement("label");
+ const row = document.createElement(isOrgfront ? "div" : "label");
row.className = "baron-org-picker__row baron-org-picker__row--member";
+ if (selected.has(selectionKey(selection))) {
+ row.classList.add("baron-org-picker__row--selected");
+ }
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
+ if (isOrgfront && mode === "single") {
+ row.append(createOrgfrontMemberSpacer());
+ const button = document.createElement("button");
+ button.className = "baron-org-picker__select";
+ button.dataset.baronOrgPickerValue = selectionKey(selection);
+ button.type = "button";
+ button.ariaPressed = String(selected.has(selectionKey(selection)));
+ button.addEventListener("click", () => onSelect(selection));
+ button.append(createOrgfrontLabelText(member.name, member.email));
+ row.append(button);
+ return row;
+ }
+
+ if (isOrgfront) {
+ row.append(createOrgfrontMemberSpacer());
+ }
row.append(
createPickerInput({
mode,
@@ -418,7 +808,11 @@ function renderMemberPickerRow(
onToggle: () => onSelect(selection),
}),
);
- row.append(createLabelText(member.name, member.email));
+ row.append(
+ isOrgfront
+ ? createOrgfrontLabelText(member.name, member.email)
+ : createLabelText(member.name, member.email),
+ );
return row;
}
@@ -452,6 +846,103 @@ function createLabelText(primary: string, secondary?: string) {
return text;
}
+function createOrgfrontMemberSpacer() {
+ const spacer = document.createElement("span");
+ spacer.className = "baron-org-picker__toggle-placeholder";
+ spacer.ariaHidden = "true";
+ return spacer;
+}
+
+function createOrgfrontLabelText(primary: string, secondary?: string) {
+ const text = document.createElement("span");
+ text.className = "baron-org-picker__label";
+ const primaryText = document.createElement("span");
+ primaryText.className = "baron-org-picker__label-primary";
+ primaryText.textContent = primary;
+ text.append(primaryText);
+ if (secondary) {
+ const secondaryText = document.createElement("span");
+ secondaryText.className = "baron-org-picker__label-secondary";
+ secondaryText.textContent = secondary;
+ text.append(secondaryText);
+ }
+ return text;
+}
+
+function filterOrgChartNode(
+ node: OrgChartNode,
+ rawQuery: string,
+ selectable: "tenant" | "user" | "both",
+): OrgChartNode | null {
+ const query = rawQuery.trim().toLowerCase();
+ if (!query) return node;
+
+ const childMatches = node.children
+ .map((child) => filterOrgChartNode(child, rawQuery, selectable))
+ .filter((child): child is OrgChartNode => Boolean(child));
+ const tenantMatch = orgTenantMatchesSearch(node, query, selectable);
+ const matchingMembers = orgMemberMatchesSearch(node, query, selectable);
+ if (
+ !tenantMatch &&
+ matchingMembers.length === 0 &&
+ childMatches.length === 0
+ ) {
+ return null;
+ }
+ return {
+ ...node,
+ members: tenantMatch ? node.members : matchingMembers,
+ children: childMatches,
+ };
+}
+
+function orgTenantMatchesSearch(
+ node: OrgChartNode,
+ query: string,
+ selectable: "tenant" | "user" | "both",
+) {
+ const tenantValues = [
+ node.id,
+ node.name,
+ node.slug,
+ node.type,
+ node.orgUnitType ?? "",
+ node.visibility,
+ ...node.domains,
+ ];
+ if (
+ selectable !== "user" &&
+ tenantValues.some((value) => value.toLowerCase().includes(query))
+ ) {
+ return true;
+ }
+ if (selectable === "tenant") {
+ return false;
+ }
+ return false;
+}
+
+function orgMemberMatchesSearch(
+ node: OrgChartNode,
+ query: string,
+ selectable: "tenant" | "user" | "both",
+) {
+ if (selectable === "tenant") {
+ return [];
+ }
+ return node.members.filter((member) =>
+ [
+ member.id ?? "",
+ member.email,
+ member.name,
+ member.department ?? "",
+ member.grade ?? "",
+ member.position ?? "",
+ member.jobTitle ?? "",
+ ].some((value) => value.toLowerCase().includes(query)),
+ );
+}
+
function collectDescendantSelections(
node: OrgChartNode,
selectable: "tenant" | "user" | "both",
diff --git a/orgfront/src/sdk/org-context-chart/orgContextChart.test.ts b/orgfront/src/sdk/org-context-chart/orgContextChart.test.ts
index 9039ef70..aa8775c9 100644
--- a/orgfront/src/sdk/org-context-chart/orgContextChart.test.ts
+++ b/orgfront/src/sdk/org-context-chart/orgContextChart.test.ts
@@ -168,4 +168,179 @@ describe("org-context chart SDK", () => {
},
]);
});
+
+ it("packages default picker UX and styles with search and descendant selection", () => {
+ const model = buildOrgChartModel(sampleOrgContext);
+ const pickerContainer = document.createElement("div");
+ const onChange = vi.fn();
+
+ const picker = renderOrgPicker(pickerContainer, model, {
+ mode: "multiple",
+ selectable: "both",
+ onChange,
+ });
+
+ expect(
+ document.head.querySelector(
+ 'style[data-baron-org-context-chart-style="default"]',
+ ),
+ ).not.toBeNull();
+ expect(
+ pickerContainer.querySelector(
+ 'input[type="search"][data-baron-org-picker-search]',
+ ),
+ ).not.toBeNull();
+ expect(
+ pickerContainer.querySelector(
+ 'input[type="checkbox"][data-baron-org-picker-descendants]',
+ ),
+ ).not.toBeNull();
+ expect(
+ pickerContainer.querySelector("[data-baron-org-picker-summary]")
+ ?.textContent,
+ ).toContain("0 selected");
+
+ const search = pickerContainer.querySelector(
+ 'input[type="search"][data-baron-org-picker-search]',
+ );
+ expect(search).not.toBeNull();
+ if (!search) return;
+ search.value = "platform";
+ search.dispatchEvent(new Event("input", { bubbles: true }));
+
+ expect(pickerContainer.textContent).toContain("Platform");
+ expect(pickerContainer.textContent).not.toContain(
+ "Leader (leader@example.com)",
+ );
+
+ search.value = "";
+ search.dispatchEvent(new Event("input", { bubbles: true }));
+
+ const descendantToggle = pickerContainer.querySelector(
+ 'input[type="checkbox"][data-baron-org-picker-descendants]',
+ );
+ expect(descendantToggle).not.toBeNull();
+ descendantToggle?.click();
+ const companyBaron = pickerContainer.querySelector(
+ 'input[value="tenant:company-baron"]',
+ );
+ expect(companyBaron).not.toBeNull();
+ companyBaron?.click();
+
+ expect(picker.getSelection()).toEqual([
+ { id: "company-baron", name: "Baron", type: "tenant" },
+ { id: "team-platform", name: "Platform", type: "tenant" },
+ {
+ id: "team-platform:engineer@example.com",
+ name: "Engineer",
+ type: "user",
+ },
+ ]);
+ expect(
+ pickerContainer.querySelector("[data-baron-org-picker-summary]")
+ ?.textContent,
+ ).toContain("3 selected");
+ expect(onChange).toHaveBeenLastCalledWith([
+ { id: "company-baron", name: "Baron", type: "tenant" },
+ { id: "team-platform", name: "Platform", type: "tenant" },
+ {
+ id: "team-platform:engineer@example.com",
+ name: "Engineer",
+ type: "user",
+ },
+ ]);
+ });
+
+ it("renders the orgfront-compatible picker UX", () => {
+ const model = buildOrgChartModel(sampleOrgContext);
+ const pickerContainer = document.createElement("div");
+ const onChange = vi.fn();
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+
+ const picker = renderOrgPicker(pickerContainer, model, {
+ mode: "single",
+ selectable: "tenant",
+ variant: "orgfront",
+ showDescendantToggle: false,
+ onCancel,
+ onChange,
+ onConfirm,
+ });
+
+ expect(
+ pickerContainer.classList.contains("baron-org-picker--orgfront"),
+ ).toBe(true);
+ expect(
+ pickerContainer.querySelector(
+ 'input[type="radio"][value="tenant:company-baron"]',
+ ),
+ ).toBeNull();
+ expect(
+ pickerContainer.querySelector("[data-baron-org-picker-search-icon]"),
+ ).not.toBeNull();
+ expect(
+ pickerContainer.querySelector(
+ "[data-baron-org-picker-search]",
+ )?.placeholder,
+ ).toBe("ID, 이름, 이메일, 메타데이터");
+ expect(
+ pickerContainer.querySelector("[data-baron-org-picker-descendants]"),
+ ).toBeNull();
+ expect(
+ pickerContainer.querySelector("[data-baron-org-picker-footer]"),
+ ).not.toBeNull();
+
+ const companyButton = pickerContainer.querySelector(
+ 'button[data-baron-org-picker-value="tenant:company-baron"]',
+ );
+ expect(companyButton).not.toBeNull();
+ companyButton?.click();
+
+ expect(onChange).toHaveBeenCalledWith([
+ { id: "company-baron", name: "Baron", type: "tenant" },
+ ]);
+ expect(picker.getSelection()).toEqual([
+ { id: "company-baron", name: "Baron", type: "tenant" },
+ ]);
+
+ const collapse = pickerContainer.querySelector(
+ 'button[data-baron-org-picker-toggle="tenant:company-baron"]',
+ );
+ expect(collapse).not.toBeNull();
+ expect(collapse?.textContent).toBe("");
+ expect(
+ collapse?.querySelector("[data-baron-org-picker-chevron]"),
+ ).not.toBeNull();
+ collapse?.click();
+ const collapsed = pickerContainer.querySelector(
+ 'button[data-baron-org-picker-toggle="tenant:company-baron"]',
+ );
+ expect(
+ collapsed
+ ?.querySelector("[data-baron-org-picker-chevron]")
+ ?.classList.contains("baron-org-picker__chevron--open"),
+ ).toBe(false);
+ expect(
+ pickerContainer.querySelector(
+ 'button[data-baron-org-picker-value="tenant:team-platform"]',
+ ),
+ ).toBeNull();
+
+ const confirm = pickerContainer.querySelector(
+ "[data-baron-org-picker-confirm]",
+ );
+ expect(confirm?.disabled).toBe(false);
+ confirm?.click();
+ expect(onConfirm).toHaveBeenCalledWith([
+ { id: "company-baron", name: "Baron", type: "tenant" },
+ ]);
+
+ pickerContainer
+ .querySelector("[data-baron-org-picker-cancel]")
+ ?.click();
+ expect(onCancel).toHaveBeenCalled();
+
+ picker.destroy();
+ });
});
diff --git a/orgfront/tailwind.config.ts b/orgfront/tailwind.config.ts
index 3f8ed216..401a9bd8 100644
--- a/orgfront/tailwind.config.ts
+++ b/orgfront/tailwind.config.ts
@@ -6,7 +6,8 @@ const config: Config = {
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
- "../common/**/*.{ts,tsx,css}",
+ "../common/core/**/*.{ts,tsx}",
+ "../common/shell/**/*.{ts,tsx}",
],
};
diff --git a/orgfront/vite.org-context-chart.config.ts b/orgfront/vite.org-context-chart.config.ts
index 64e9ee0e..1ec8e95a 100644
--- a/orgfront/vite.org-context-chart.config.ts
+++ b/orgfront/vite.org-context-chart.config.ts
@@ -2,7 +2,17 @@ import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const isMinifiedBuild = process.env.ORG_CONTEXT_CHART_MINIFY === "true";
+const buildId = process.env.ORG_CONTEXT_CHART_BUILD_ID ?? createBuildId();
const fileSuffix = isMinifiedBuild ? ".min" : "";
+const fileBaseName = `boc-${buildId}${fileSuffix}`;
+
+function createBuildId() {
+ const now = new Date();
+ const year = String(now.getFullYear()).slice(-2);
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
+ return `${year}${month}${random}`;
+}
export default defineConfig({
build: {
@@ -12,9 +22,7 @@ export default defineConfig({
new URL("./src/sdk/org-context-chart/index.ts", import.meta.url),
),
fileName: (format) =>
- format === "es"
- ? `baron-org-context-chart${fileSuffix}.js`
- : `baron-org-context-chart${fileSuffix}.umd.cjs`,
+ format === "es" ? `${fileBaseName}.js` : `${fileBaseName}.umd.cjs`,
formats: ["es", "umd"],
name: "BaronOrgContextChart",
},
diff --git a/test/adminfront_dev_performance_policy_test.sh b/test/adminfront_dev_performance_policy_test.sh
new file mode 100644
index 00000000..971d9b61
--- /dev/null
+++ b/test/adminfront_dev_performance_policy_test.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+assert_contains() {
+ local file="$1"
+ local pattern="$2"
+ grep -Fq -- "$pattern" "$file" || fail "$file must contain: $pattern"
+}
+
+assert_not_contains() {
+ local file="$1"
+ local pattern="$2"
+ if grep -Fq -- "$pattern" "$file"; then
+ fail "$file must not contain: $pattern"
+ fi
+}
+
+for config in \
+ "$ROOT_DIR/adminfront/tailwind.config.ts" \
+ "$ROOT_DIR/devfront/tailwind.config.ts" \
+ "$ROOT_DIR/orgfront/tailwind.config.ts"
+do
+ assert_not_contains "$config" "../common/**/*.{ts,tsx,css}"
+ assert_contains "$config" "../common/core/**/*.{ts,tsx}"
+ assert_contains "$config" "../common/shell/**/*.{ts,tsx}"
+done
+
+assert_contains "$ROOT_DIR/common/config/vite.base.ts" "/workspace/common"
+
+assert_not_contains \
+ "$ROOT_DIR/adminfront/src/features/tenants/routes/TenantDetailPage.tsx" \
+ "export function canShowWorksmobileEntry"
+assert_not_contains \
+ "$ROOT_DIR/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx" \
+ "export function createSchemaField"
+assert_not_contains \
+ "$ROOT_DIR/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx" \
+ "export function buildWorksmobilePasswordManageUrl"
+assert_not_contains \
+ "$ROOT_DIR/adminfront/src/features/tenants/components/ParentTenantSelector.tsx" \
+ "export function filterParentTenants"
+
+echo "OK: adminfront dev performance settings avoid wide scans and route HMR invalidation"
diff --git a/test/frontend_dev_bind_mount_policy_test.sh b/test/frontend_dev_bind_mount_policy_test.sh
new file mode 100644
index 00000000..c28f0c4c
--- /dev/null
+++ b/test/frontend_dev_bind_mount_policy_test.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+COMPOSE_FILE="$ROOT_DIR/docker-compose.yaml"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+assert_contains() {
+ local pattern="$1"
+ grep -Fq -- "$pattern" "$COMPOSE_FILE" || fail "docker-compose.yaml must contain: $pattern"
+}
+
+assert_not_contains() {
+ local pattern="$1"
+ if grep -Fq -- "$pattern" "$COMPOSE_FILE"; then
+ fail "docker-compose.yaml must not contain stale frontend mount: $pattern"
+ fi
+}
+
+for app in adminfront devfront orgfront; do
+ assert_contains "./$app:/workspace/$app"
+ assert_contains "/workspace/$app/node_modules"
+ assert_not_contains "./$app:/app"
+done
+
+assert_contains "./common:/workspace/common"
+assert_contains "/workspace/common/node_modules"
+assert_contains "./locales:/workspace/locales"
+
+for runtime in \
+ "$ROOT_DIR/adminfront/scripts/runtime-mode.sh" \
+ "$ROOT_DIR/devfront/scripts/runtime-mode.sh" \
+ "$ROOT_DIR/orgfront/scripts/runtime-mode.sh"
+do
+ grep -Fq -- "/workspace/common" "$runtime" || fail "$runtime must install dependencies from /workspace/common"
+ grep -Fq -- "pnpm install --filter" "$runtime" || fail "$runtime must install only its workspace slice"
+ grep -Fq -- "--frozen-lockfile --ignore-scripts" "$runtime" || fail "$runtime must preserve the workspace lockfile with pnpm"
+ if grep -Fq -- "npm install --no-workspaces" "$runtime"; then
+ fail "$runtime must not install common dependencies outside the workspace graph"
+ fi
+done
+
+echo "OK: frontend dev containers bind-mount source into Dockerfile WORKDIR paths"
diff --git a/test/orgfront_integration_policy_test.sh b/test/orgfront_integration_policy_test.sh
index 577e7ca6..31595e90 100644
--- a/test/orgfront_integration_policy_test.sh
+++ b/test/orgfront_integration_policy_test.sh
@@ -50,8 +50,8 @@ do
fi
done
-assert_contains "$LOCAL_COMPOSE" "context: ./orgfront"
-assert_contains "$LOCAL_COMPOSE" "./orgfront:/app"
+assert_contains "$LOCAL_COMPOSE" "dockerfile: ./orgfront/Dockerfile"
+assert_contains "$LOCAL_COMPOSE" "./orgfront:/workspace/orgfront"
assert_not_contains "$LOCAL_COMPOSE" "../baron-orgchart"
for file in "$STAGING_COMPOSE" "$PULL_COMPOSE" "$DEPLOY_TEMPLATE"; do
@@ -74,9 +74,9 @@ assert_contains "$BUILD_RC" "context: ./orgfront"
assert_contains "$BUILD_RC" "/baron_sso/orgfront:"
assert_contains "$CODE_CHECK" "run_orgfront_tests"
-assert_contains "$CODE_CHECK" "orgfront/package-lock.json"
assert_contains "$CODE_CHECK" "cd orgfront"
-assert_contains "$CODE_CHECK" "npm test"
+assert_contains "$CODE_CHECK" "pnpm install -C ../common --no-frozen-lockfile"
+assert_contains "$CODE_CHECK" "pnpm run test"
assert_contains "$STAGING_RELEASE" "ORGFRONT_IMAGE_NAME"
assert_contains "$STAGING_RELEASE" "ORGFRONT_PORT="
diff --git a/test/orgfront_org_context_chart_package_test.sh b/test/orgfront_org_context_chart_package_test.sh
index 761bb7b6..eff3e828 100644
--- a/test/orgfront_org_context_chart_package_test.sh
+++ b/test/orgfront_org_context_chart_package_test.sh
@@ -16,7 +16,17 @@ assert_contains() {
}
assert_contains orgfront/package.json "build:org-context-chart:min"
+assert_contains orgfront/package.json "scripts/build-org-context-chart.mjs"
+assert_contains orgfront/scripts/build-org-context-chart.mjs "ORG_CONTEXT_CHART_BUILD_ID"
assert_contains orgfront/vite.org-context-chart.config.ts "ORG_CONTEXT_CHART_MINIFY"
+assert_contains orgfront/vite.org-context-chart.config.ts "ORG_CONTEXT_CHART_BUILD_ID"
+assert_contains orgfront/vite.org-context-chart.config.ts "boc-"
assert_contains orgfront/vite.org-context-chart.config.ts ".min"
+assert_contains orgfront/scripts/build-org-context-chart.mjs 'return `${year}${month}${random}`;'
+assert_contains orgfront/vite.org-context-chart.config.ts 'return `${year}${month}${random}`;'
-echo "OK: OrgContext chart package emits explicit minified bundles"
+if grep -Fq '${year}${month}${day}' orgfront/scripts/build-org-context-chart.mjs orgfront/vite.org-context-chart.config.ts; then
+ fail "OrgContext chart build id must use YYMM plus 4 random digits, without day or separators"
+fi
+
+echo "OK: OrgContext chart package emits timestamped short bundle names"
diff --git a/test/shell_layout_policy_test.sh b/test/shell_layout_policy_test.sh
new file mode 100644
index 00000000..f3eb763f
--- /dev/null
+++ b/test/shell_layout_policy_test.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+LAYOUT_FILE="$ROOT_DIR/common/shell/layout.ts"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+assert_contains() {
+ local pattern="$1"
+ grep -Fq -- "$pattern" "$LAYOUT_FILE" || fail "common shell layout must contain: $pattern"
+}
+
+assert_not_contains() {
+ local pattern="$1"
+ if grep -Fq -- "$pattern" "$LAYOUT_FILE"; then
+ fail "common shell layout must not contain: $pattern"
+ fi
+}
+
+assert_contains "root: \"grid min-h-screen grid-cols-[240px,minmax(0,1fr)]"
+assert_not_contains "md:grid-cols-[240px,1fr]"
+assert_contains "aside:"
+assert_contains "sticky top-0 h-screen"
+assert_not_contains "border-b border-border bg-card md:sticky"
+
+echo "OK: shell layout keeps the navigation in a fixed left column"