From d32ca69eee92a116bd77b694f41175737d1e700e Mon Sep 17 00:00:00 2001 From: Lectom Date: Tue, 2 Jun 2026 18:05:36 +0900 Subject: [PATCH] feat: improve Worksmobile tenant sync handling --- adminfront/public/LINE_WORKS_Appicon.svg | 7 + adminfront/seed-tenant.csv | 23 +- adminfront/src/app/routes.tsx | 2 +- .../src/components/layout/AppLayout.test.tsx | 22 + .../src/components/layout/AppLayout.tsx | 106 +- .../integrity/DataIntegrityPage.test.tsx | 50 + .../features/integrity/DataIntegrityPage.tsx | 441 +++++---- .../projections/UserProjectionPage.tsx | 109 ++- .../components/ParentTenantSelector.tsx | 57 +- .../tenants/routes/TenantCreatePage.tsx | 29 +- .../routes/TenantDetailPage.helpers.ts | 7 - .../tenants/routes/TenantDetailPage.test.ts | 30 - .../tenants/routes/TenantDetailPage.tsx | 15 - .../TenantDetailPage.worksmobile.test.tsx | 12 +- .../tenants/routes/TenantListPage.tsx | 2 +- .../tenants/routes/TenantProfilePage.tsx | 304 +++--- .../tenants/routes/TenantWorksmobilePage.tsx | 915 +++++++++++------- .../tenants/routes/worksmobileAccess.test.ts | 57 ++ .../tenants/routes/worksmobileAccess.ts | 60 ++ .../features/tenants/utils/orgConfig.test.ts | 61 +- .../src/features/tenants/utils/orgConfig.ts | 22 + .../tenants/utils/tenantCsvImport.test.ts | 13 +- .../features/tenants/utils/tenantCsvImport.ts | 33 + .../src/features/users/UserCreatePage.tsx | 126 ++- .../src/features/users/UserDetailPage.tsx | 135 +-- .../users/components/UserBulkUploadModal.tsx | 1 + .../src/features/users/orgChartPicker.test.ts | 67 ++ .../src/features/users/orgChartPicker.ts | 93 ++ adminfront/src/lib/adminApi.ts | 11 + adminfront/tests/tenants.spec.ts | 107 +- adminfront/tests/users.spec.ts | 30 +- adminfront/tests/worksmobile.spec.ts | 203 +++- backend/cmd/server/main.go | 2 + backend/internal/bootstrap/tenant_seed.go | 84 ++ .../internal/bootstrap/tenant_seed_test.go | 102 +- backend/internal/handler/tenant_handler.go | 180 ++-- .../internal/handler/tenant_handler_test.go | 22 +- .../handler/user_group_handler_test.go | 3 + backend/internal/handler/user_handler.go | 96 +- backend/internal/handler/user_handler_test.go | 43 + .../internal/handler/worksmobile_handler.go | 8 + .../handler/worksmobile_handler_test.go | 25 + backend/internal/repository/main_test.go | 2 +- .../worksmobile_outbox_repository.go | 92 +- .../worksmobile_outbox_repository_test.go | 125 +++ .../internal/service/user_group_service.go | 10 + .../service/user_group_service_test.go | 72 ++ .../service/worksmobile_client_test.go | 109 ++- .../internal/service/worksmobile_mapper.go | 19 + .../service/worksmobile_mapper_test.go | 63 ++ .../service/worksmobile_relay_worker.go | 95 +- .../service/worksmobile_sync_service.go | 75 +- .../service/worksmobile_sync_service_test.go | 318 +++++- ...worksmobile-halla-domain-migration-plan.md | 126 +++ .../orgchart/hanmacFamilyOrder.test.ts | 90 +- .../features/orgchart/hanmacFamilyOrder.ts | 74 +- .../src/features/orgchart/pickerTree.test.ts | 364 ++++--- tenants_2605.csv | 86 +- 58 files changed, 4035 insertions(+), 1400 deletions(-) create mode 100644 adminfront/public/LINE_WORKS_Appicon.svg delete mode 100644 adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts delete mode 100644 adminfront/src/features/tenants/routes/TenantDetailPage.test.ts create mode 100644 adminfront/src/features/tenants/routes/worksmobileAccess.test.ts create mode 100644 adminfront/src/features/tenants/routes/worksmobileAccess.ts create mode 100644 backend/internal/repository/worksmobile_outbox_repository_test.go create mode 100644 docs/worksmobile-halla-domain-migration-plan.md diff --git a/adminfront/public/LINE_WORKS_Appicon.svg b/adminfront/public/LINE_WORKS_Appicon.svg new file mode 100644 index 00000000..7cf048cd --- /dev/null +++ b/adminfront/public/LINE_WORKS_Appicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/adminfront/seed-tenant.csv b/adminfront/seed-tenant.csv index f774259a..5ff8391f 100644 --- a/adminfront/seed-tenant.csv +++ b/adminfront/seed-tenant.csv @@ -1,11 +1,12 @@ -id,name,type,parent_tenant_slug,slug,memo,email_domain -038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트, -9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com -369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr -5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr -96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID, -c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com -b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr -5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr -e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr -9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트, +id,name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync +038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,,,, +5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr,,, +9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com,,, +369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr,,, +96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,brsw.kr,,, +5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,hanmac-family,halla,네이버웍스 한라 HALLA_DOMAIN_ID,hallasanup.com,,, +c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com,,, +b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,, +e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,, +4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no +9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,, diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 47029154..0d44cda1 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -48,6 +48,7 @@ export const adminRoutes: RouteObject[] = [ { path: "users/:id", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, + { path: "worksmobile", element: }, { path: "tenants/:tenantId", element: , @@ -56,7 +57,6 @@ export const adminRoutes: RouteObject[] = [ { path: "permissions", element: }, { path: "organization", element: }, { path: "schema", element: }, - { path: "worksmobile", element: }, ], }, { diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx index 39fa215b..51a75576 100644 --- a/adminfront/src/components/layout/AppLayout.test.tsx +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -70,6 +70,7 @@ function renderLayout(entry = "/users") { path="tenants/:tenantId" element={
Tenant outlet
} /> + Worksmobile outlet} /> Login outlet} /> @@ -99,7 +100,28 @@ describe("admin AppLayout", () => { expect(screen.getByText("Admin Control")).toBeInTheDocument(); expect(screen.getByText("Users outlet")).toBeInTheDocument(); expect(screen.getByText("Tenants")).toBeInTheDocument(); + expect(screen.getByText("Worksmobile")).toBeInTheDocument(); expect(screen.getByText("Data Integrity")).toBeInTheDocument(); + expect(screen.queryByText("User Projection")).not.toBeInTheDocument(); + const navigation = screen.getByRole("navigation"); + const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) => + link.textContent?.trim(), + ); + expect(navLabels).toEqual([ + "Overview", + "Tenants", + "Worksmobile", + "Users", + "Data Integrity", + "Auth Guard", + "API Keys", + "Audit Logs", + ]); + const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon"); + expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg"); + expect(worksmobileIcon).toHaveAttribute("fill", "none"); + expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4); + expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull(); }); it("opens profile menu, navigates, toggles theme/session, and logs out", async () => { diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 0c46e736..e6593aee 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -2,13 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { Building2, ChevronDown, - Database, Key, KeyRound, LayoutDashboard, LogOut, Moon, - Network, NotebookTabs, ShieldCheck, ShieldHalf, @@ -32,7 +30,7 @@ import { shellLayoutClasses, writeShellSessionExpiryEnabled, } from "../../../../common/shell"; -import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; +import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess"; import { fetchMe } from "../../lib/adminApi"; import { debugLog } from "../../lib/debugLog"; import { t } from "../../lib/i18n"; @@ -61,6 +59,12 @@ const staticNavItems: ShellSidebarNavItem[] = [ to: "/users", icon: Users, }, + { + labelKey: "ui.admin.nav.auth_guard", + labelFallback: "Auth Guard", + to: "/auth", + icon: KeyRound, + }, { labelKey: "ui.admin.nav.api_keys", labelFallback: "API Keys", @@ -73,12 +77,6 @@ const staticNavItems: ShellSidebarNavItem[] = [ to: "/audit-logs", icon: NotebookTabs, }, - { - labelKey: "ui.admin.nav.auth_guard", - labelFallback: "Auth Guard", - to: "/auth", - icon: KeyRound, - }, ]; type SessionStatusProps = { @@ -123,6 +121,38 @@ function SessionStatusText(props: SessionStatusProps) { return <>{sessionStatus.text}; } +function LineWorksNavIcon({ size = 18 }: { size?: number | string }) { + const iconSize = typeof size === "number" ? size : Number.parseFloat(size); + return ( + + ); +} + function AppLayout() { const auth = useAuth(); const location = useLocation(); @@ -178,11 +208,10 @@ function AppLayout() { const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole); const isTenantAdmin = effectiveRole === "tenant_admin"; const manageableCount = profile?.manageableTenants?.length ?? 0; - const orgfrontUrl = buildAuthenticatedOrgChartUrl( - import.meta.env.ORGFRONT_URL || "http://localhost:5175", - { includeInternal: true }, - ); - + const showWorksmobile = canAccessWorksmobile({ + ...profile, + role: effectiveRole ?? profile?.role, + }); const filteredItems = items.filter((item) => { if (isTest) return true; if (item.to === "/api-keys") return isSuperAdmin; @@ -196,20 +225,15 @@ function AppLayout() { to: "/tenants", icon: Building2, }); - filteredItems.splice(2, 0, { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }); + if (showWorksmobile) { + filteredItems.splice(2, 0, { + labelKey: "ui.admin.nav.worksmobile", + labelFallback: "Worksmobile", + to: "/worksmobile", + icon: LineWorksNavIcon, + }); + } filteredItems.splice(4, 0, { - labelKey: "ui.admin.nav.user_projection", - labelFallback: "User Projection", - to: "/system/projections/users", - icon: Database, - }); - filteredItems.splice(5, 0, { labelKey: "ui.admin.nav.data_integrity", labelFallback: "Data Integrity", to: "/system/data-integrity", @@ -231,26 +255,14 @@ function AppLayout() { icon: Building2, }); } - filteredItems.splice( - manageableCount <= 1 && profile?.tenantId ? 2 : 2, - 0, - { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }, - ); - } else { - // 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다. - filteredItems.splice(1, 0, { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }); + if (showWorksmobile) { + filteredItems.splice(2, 0, { + labelKey: "ui.admin.nav.worksmobile", + labelFallback: "Worksmobile", + to: "/worksmobile", + icon: LineWorksNavIcon, + }); + } } return filteredItems; diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 06a138d4..973a4ca4 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -6,6 +6,9 @@ import { fetchDataIntegrityReport, fetchMe, fetchOrphanUserLoginIDs, + fetchUserProjectionStatus, + reconcileUserProjection, + resetUserProjection, } from "../../lib/adminApi"; import { createI18nMock } from "../../test/i18nMock"; import DataIntegrityPage from "./DataIntegrityPage"; @@ -60,6 +63,24 @@ vi.mock("../../lib/adminApi", () => ({ ], total: 1, })), + fetchUserProjectionStatus: vi.fn(async () => ({ + name: "kratos_users", + status: "ready", + ready: true, + lastSyncedAt: "2026-05-11T03:00:00Z", + updatedAt: "2026-05-11T03:00:10Z", + projectedUsers: 152, + })), + reconcileUserProjection: vi.fn(async () => ({ + status: "success", + syncedUsers: 152, + updatedAt: "2026-05-11T03:01:00Z", + })), + resetUserProjection: vi.fn(async () => ({ + status: "success", + syncedUsers: 152, + updatedAt: "2026-05-11T03:02:00Z", + })), deleteOrphanUserLoginIDs: vi.fn(async () => ({ deletedCount: 1, deleted: [ @@ -95,6 +116,7 @@ describe("DataIntegrityPage", () => { beforeEach(() => { currentRole = "super_admin"; vi.clearAllMocks(); + vi.spyOn(window, "confirm").mockReturnValue(true); window.localStorage.setItem("locale", "ko"); }); @@ -102,6 +124,12 @@ describe("DataIntegrityPage", () => { renderPage(); expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "정합성 검사" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "사용자 동기화" }), + ).toBeInTheDocument(); expect( await screen.findByText( "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.", @@ -113,6 +141,28 @@ describe("DataIntegrityPage", () => { expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1); }); + it("renders user projection sync inside data integrity", async () => { + renderPage(); + + fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" })); + + expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument(); + expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument(); + expect(screen.getByText("준비됨")).toBeInTheDocument(); + expect(screen.getByText("152")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /재동기화/ })); + await waitFor(() => { + expect(reconcileUserProjection).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ })); + await waitFor(() => { + expect(resetUserProjection).toHaveBeenCalledTimes(1); + }); + expect(fetchUserProjectionStatus).toHaveBeenCalled(); + }); + it("shows orphan login ID targets and deletes selected rows", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); renderPage(); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 1d4ca4ef..5dc81c20 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -19,6 +19,7 @@ import { } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { getAdminDateLocale } from "../../lib/locale"; +import { UserProjectionContent } from "../projections/UserProjectionPage"; function statusLabel(status: DataIntegrityStatus) { switch (status) { @@ -187,6 +188,14 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") { } } +function pageTabClassName(active: boolean) { + return `relative px-6 py-3 text-sm font-medium transition-colors ${ + active + ? "border-b-2 border-primary text-primary" + : "text-muted-foreground hover:text-foreground" + }`; +} + function OrphanLoginIDTable({ items, selectedIds, @@ -284,6 +293,9 @@ function OrphanLoginIDTable({ function DataIntegrityContent() { const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState<"integrity" | "projection">( + "integrity", + ); const [selectedOrphanIds, setSelectedOrphanIds] = useState([]); const [recheckStatus, setRecheckStatus] = useState< "idle" | "running" | "success" | "error" @@ -360,210 +372,243 @@ function DataIntegrityContent() {

-
- - {recheckMessage ? ( - - {recheckMessage} - - ) : null} -
- - -
- {isError ? ( -
- {(error as Error)?.message || - t( - "msg.admin.integrity.report.load_error", - "정합성 리포트를 불러오지 못했습니다.", - )} -
- ) : null} - -
-
-
-

- {t( - "ui.admin.integrity.read_model.title", - "Read model integrity", - )} -

-

- {t( - "msg.admin.integrity.read_model.description", - "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", - )} -

-
- {data ? ( - - {statusLabel(data.status)} - - ) : null} -
- - {isLoading ? ( -
- {t("ui.admin.integrity.loading", "불러오는 중")} -
- ) : ( -
-
-
- {t("ui.admin.integrity.summary.total_checks", "검사 항목")} -
-
- {data?.summary.totalChecks ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.passed", "정상")} -
-
- {data?.summary.passed ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.failures", "실패 건수")} -
-
- {data?.summary.failures ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.checked_at", "검사 시각")} -
-
- {formatDateTime(data?.checkedAt)} -
-
-
- )} -
- -
- {(data?.sections ?? []).map((section) => ( -
-
-
-

- {integritySectionLabel(section.key, section.label)} -

-

- {integritySectionDescription(section.key)} -

-
- - {statusLabel(section.status)} - -
-
- {section.checks.map((check) => ( -
-
- -
-
- {integrityCheckLabel(check.key, check.label)} -
-

- {integrityCheckDescription( - check.key, - check.description, - )} -

-
-
-
- - {statusLabel(check.status)} - - - {check.count} - -
-
- ))} -
-
- ))} -
- -
-
-
-

- {t( - "ui.admin.integrity.orphan_login_ids.title", - "유령 로그인 ID 정리", - )} -

-

- {t( - "msg.admin.integrity.orphan_login_ids.description", - "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", - )} -

-
+ {activeTab === "integrity" ? ( +
+ {recheckMessage ? ( + + {recheckMessage} + + ) : null}
- {orphanLoginIDsQuery.isError ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.load_error", - "유령 로그인 ID 대상을 불러오지 못했습니다.", - )} -
- ) : null} - {deleteMutation.data ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.delete_success", - "{{count}}개의 유령 로그인 ID를 삭제했습니다.", - { count: deleteMutation.data.deletedCount }, - )} -
- ) : null} - -
+ ) : null} + + +
+ +
+ + {activeTab === "integrity" ? ( +
+ {isError ? ( +
+ {(error as Error)?.message || + t( + "msg.admin.integrity.report.load_error", + "정합성 리포트를 불러오지 못했습니다.", + )} +
+ ) : null} + +
+
+
+

+ {t( + "ui.admin.integrity.read_model.title", + "Read model integrity", + )} +

+

+ {t( + "msg.admin.integrity.read_model.description", + "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", + )} +

+
+ {data ? ( + + {statusLabel(data.status)} + + ) : null} +
+ + {isLoading ? ( +
+ {t("ui.admin.integrity.loading", "불러오는 중")} +
+ ) : ( +
+
+
+ {t("ui.admin.integrity.summary.total_checks", "검사 항목")} +
+
+ {data?.summary.totalChecks ?? 0} +
+
+
+
+ {t("ui.admin.integrity.summary.passed", "정상")} +
+
+ {data?.summary.passed ?? 0} +
+
+
+
+ {t("ui.admin.integrity.summary.failures", "실패 건수")} +
+
+ {data?.summary.failures ?? 0} +
+
+
+
+ {t("ui.admin.integrity.summary.checked_at", "검사 시각")} +
+
+ {formatDateTime(data?.checkedAt)} +
+
+
+ )} +
+ +
+ {(data?.sections ?? []).map((section) => ( +
+
+
+

+ {integritySectionLabel(section.key, section.label)} +

+

+ {integritySectionDescription(section.key)} +

+
+ + {statusLabel(section.status)} + +
+
+ {section.checks.map((check) => ( +
+
+ +
+
+ {integrityCheckLabel(check.key, check.label)} +
+

+ {integrityCheckDescription( + check.key, + check.description, + )} +

+
+
+
+ + {statusLabel(check.status)} + + + {check.count} + +
+
+ ))} +
+
+ ))} +
+ +
+
+
+

+ {t( + "ui.admin.integrity.orphan_login_ids.title", + "유령 로그인 ID 정리", + )} +

+

+ {t( + "msg.admin.integrity.orphan_login_ids.description", + "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", + )} +

+
+ +
+ {orphanLoginIDsQuery.isError ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.load_error", + "유령 로그인 ID 대상을 불러오지 못했습니다.", + )} +
+ ) : null} + {deleteMutation.data ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.delete_success", + "{{count}}개의 유령 로그인 ID를 삭제했습니다.", + { count: deleteMutation.data.deletedCount }, + )} +
+ ) : null} + +
+
+ ) : ( +
+ +
+ )} ); } diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx index 9ec98255..d1f27743 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -55,7 +55,11 @@ function ProjectionStatusBadge({ ); } -function UserProjectionContent() { +export function UserProjectionContent({ + embedded = false, +}: { + embedded?: boolean; +}) { const queryClient = useQueryClient(); const { data, isLoading, isError, error } = useQuery({ queryKey: ["user-projection-status"], @@ -94,50 +98,55 @@ function UserProjectionContent() { const actionResult = reconcileMutation.data ?? resetMutation.data; const actionError = reconcileMutation.error ?? resetMutation.error; - return ( -
-
-
-
- -
-
-

- {t( - "ui.admin.user_projection.title", - "User Projection Management", - )} -

-

- {t( - "msg.admin.user_projection.subtitle", - "Review and sync the Kratos user read model.", - )} -

-
+ const header = ( +
+
+
+
-
- - +
+

+ {t("ui.admin.user_projection.title", "User Projection Management")} +

+

+ {t( + "msg.admin.user_projection.subtitle", + "Review and sync the Kratos user read model.", + )} +

-
+
+
+ + +
+
+ ); + const body = ( + <> {isError ? (
{(error as Error)?.message || @@ -243,6 +252,22 @@ function UserProjectionContent() {
) : null} + + ); + + if (embedded) { + return ( +
+ {header} + {body} +
+ ); + } + + return ( +
+ {header} + {body}
); } diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index bac4fe1c..fd5df2db 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -33,6 +33,8 @@ type ParentTenantSelectorProps = { orgChartPickerLabel?: string; localPickerLabel?: string; localTenantFilter?: (tenant: TenantSummary) => boolean; + compact?: boolean; + controlTestId?: string; }; export function ParentTenantSelector({ @@ -49,6 +51,8 @@ export function ParentTenantSelector({ orgChartPickerLabel, localPickerLabel, localTenantFilter, + compact = false, + controlTestId, }: ParentTenantSelectorProps) { const [pickerOpen, setPickerOpen] = useState(false); const [localPickerOpen, setLocalPickerOpen] = useState(false); @@ -81,19 +85,37 @@ export function ParentTenantSelector({ }, [excludeTenantId, onChange, pickerOpen]); return ( -
-
+
+
{labelAction}
-
+
- @@ -185,14 +207,23 @@ export function ParentTenantSelector({ )} {selectedTenant ? ( <> - - {selectedTenant.slug} · {selectedTenant.type} + + {compact + ? `${selectedTenant.name} · ${selectedTenant.slug}` + : `${selectedTenant.slug} · ${selectedTenant.type}`} ) : ( - {noneLabel} + + {noneLabel} + )} {contextLabel && ( diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 2fca4b48..f21dd5d3 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -4,6 +4,7 @@ import { Building2, Sparkles } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Button } from "../../../components/ui/button"; +import { Checkbox } from "../../../components/ui/checkbox"; import { Card, CardContent, @@ -46,6 +47,7 @@ function TenantCreatePage() { const [parentStepConfirmed, setParentStepConfirmed] = useState(false); const [orgUnitType, setOrgUnitType] = useState(""); const [visibility, setVisibility] = useState("public"); + const [worksmobileExcluded, setWorksmobileExcluded] = useState(false); const [description, setDescription] = useState(""); const [status, setStatus] = useState("active"); const [domains, setDomains] = useState([]); @@ -109,7 +111,11 @@ function TenantCreatePage() { status, domains, config: canConfigureHanmacOrg - ? mergeTenantOrgConfig(undefined, { orgUnitType, visibility }) + ? mergeTenantOrgConfig(undefined, { + orgUnitType, + visibility, + worksmobileExcluded, + }) : undefined, forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts, }), @@ -284,6 +290,27 @@ function TenantCreatePage() { ))}
+
+ + setWorksmobileExcluded(checked === true) + } + /> + +
)}
diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts b/adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts deleted file mode 100644 index e3eb9e1b..00000000 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function canShowWorksmobileEntry(tenant?: { - id?: string; - slug?: string; - parentId?: string | null; -}) { - return tenant?.slug === "hanmac-family" && !tenant.parentId; -} diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts b/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts deleted file mode 100644 index 4c102e08..00000000 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers"; - -describe("TenantDetailPage Worksmobile entry visibility", () => { - it("shows Worksmobile entry only for hanmac-family root tenant", () => { - expect( - canShowWorksmobileEntry({ - id: "hanmac-family-id", - slug: "hanmac-family", - parentId: undefined, - }), - ).toBe(true); - - expect( - canShowWorksmobileEntry({ - id: "hanmac-child-id", - slug: "hanmac-family", - parentId: "root-id", - }), - ).toBe(false); - - expect( - canShowWorksmobileEntry({ - id: "other-id", - slug: "other", - parentId: undefined, - }), - ).toBe(false); - }); -}); diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index b7bae980..2d116682 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -5,7 +5,6 @@ import { Button } from "../../../components/ui/button"; import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { normalizeAdminRole } from "../../../lib/roles"; -import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers"; function TenantDetailPage() { const params = useParams<{ tenantId: string }>(); @@ -26,8 +25,6 @@ function TenantDetailPage() { const profileRole = normalizeAdminRole(profile?.role); const canAccessSchema = profileRole === "super_admin" || profileRole === "tenant_admin"; - const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data); - const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); const isWorksmobileTab = location.pathname.includes("/worksmobile"); @@ -125,18 +122,6 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} )} - {showWorksmobileEntry && ( - - {t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")} - - )}
{/* Outlet for nested routes */} diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx index 8bff6fa2..824a4bde 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx @@ -29,7 +29,6 @@ function renderTenantDetailPage() { }> profile
} /> - worksmobile
} />
@@ -42,16 +41,11 @@ describe("TenantDetailPage Worksmobile navigation", () => { vi.clearAllMocks(); }); - it("opens Worksmobile management in the current admin route", async () => { + it("does not render Worksmobile as a tenant detail tab", async () => { renderTenantDetailPage(); - const link = await screen.findByRole("link", { name: /Worksmobile/i }); + await screen.findByText("프로필"); - expect(link).toHaveAttribute( - "href", - "/tenants/hanmac-family-id/worksmobile", - ); - expect(link).not.toHaveAttribute("target"); - expect(link).not.toHaveAttribute("rel"); + expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull(); }); }); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d1569117..16ac296a 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -116,7 +116,7 @@ import { } from "./tenantListView"; const tenantCSVTemplate = - "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; + "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n"; const tenantPageSize = 500; const _tenantVirtualizationThreshold = 250; const _tenantEstimatedRowHeight = 73; diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 3cb672c2..1e979036 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -30,8 +30,8 @@ import { type ServerDomainConflict, } from "../utils/domainTags"; import { + getOrgUnitTypeOptionsForTenantType, mergeTenantOrgConfig, - ORG_UNIT_TYPE_OPTIONS, readTenantOrgConfig, removeTenantOrgConfig, shouldAllowHanmacOrgConfig, @@ -70,6 +70,7 @@ export function TenantProfilePage() { const [orgUnitType, setOrgUnitType] = useState(""); const [tenantVisibility, setTenantVisibility] = useState("public"); + const [worksmobileExcluded, setWorksmobileExcluded] = useState(false); useEffect(() => { if (tenantQuery.data) { @@ -84,6 +85,7 @@ export function TenantProfilePage() { setParentId(tenantQuery.data.parentId ?? ""); setOrgUnitType(orgConfig.orgUnitType); setTenantVisibility(orgConfig.visibility); + setWorksmobileExcluded(orgConfig.worksmobileExcluded); } }, [tenantQuery.data]); @@ -101,6 +103,7 @@ export function TenantProfilePage() { orgConfigCandidate, ]) : false; + const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type); const updateMutation = useMutation({ mutationFn: (overrideForceDomains?: string[]) => { @@ -109,6 +112,7 @@ export function TenantProfilePage() { ? mergeTenantOrgConfig(baseConfig, { orgUnitType, visibility: tenantVisibility, + worksmobileExcluded, }) : removeTenantOrgConfig(baseConfig); @@ -226,78 +230,46 @@ export function TenantProfilePage() { return ( <> - - - - {t("ui.admin.tenants.profile.title", "테넌트 프로필")} - - - {t( - "ui.admin.tenants.profile.subtitle", - "슬러그 및 상태 변경은 즉시 적용됩니다.", - )} - + + +
+
+ + {t("ui.admin.tenants.profile.title", "테넌트 프로필")} + + + {t( + "ui.admin.tenants.profile.subtitle", + "슬러그 및 상태 변경은 즉시 적용됩니다.", + )} + +
+
- + {loadError && (
{loadError}
)} -
- - setName(e.target.value)} /> -
-
- - -
-
+
+ + setName(e.target.value)} /> +
+
+ + setSlug(e.target.value)} /> +
+
+
+
+
+ + +
{canEditOrgConfig && ( <>
-
+
@@ -360,68 +376,92 @@ export function TenantProfilePage() { ))}
+
+ + +
)}
-
- - setSlug(e.target.value)} /> -
-
- -