From 6cdd0fd81e93460c7f62290f978bf95d691a4a64 Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 6 May 2026 10:37:34 +0900 Subject: [PATCH] =?UTF-8?q?worksmobile=20=EA=B4=80=EB=A6=AC=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B4=EC=99=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 4 +- .../tenants/routes/TenantListPage.tsx | 35 +- .../routes/TenantWorksmobilePage.test.ts | 195 +++++ .../tenants/routes/TenantWorksmobilePage.tsx | 689 +++++++++++++----- adminfront/src/lib/adminApi.ts | 2 + adminfront/tests/tenants.spec.ts | 10 + adminfront/tests/worksmobile.spec.ts | 186 ++++- .../service/worksmobile_sync_service.go | 2 + .../service/worksmobile_sync_service_test.go | 20 + ...smobile-directory-sync-technical-review.md | 19 + 10 files changed, 943 insertions(+), 219 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 4f71fe3d..78f97232 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -534,7 +534,7 @@ function AppLayout() { -
+
@@ -730,7 +730,7 @@ function AppLayout() {
-
+
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index e86c2db8..666ffb6f 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -483,10 +483,10 @@ function TenantListPage() {
- +
- + 0 && @@ -498,28 +498,28 @@ function TenantListPage() { } /> - + {t("ui.admin.tenants.table.id", "ID")} - + {t("ui.admin.tenants.table.name", "NAME")} - + {t("ui.admin.tenants.table.type", "TYPE")} - + {t("ui.admin.tenants.table.slug", "SLUG")} - + {t("ui.admin.tenants.table.status", "STATUS")} - + {t("ui.admin.tenants.table.members", "MEMBERS")} - + {t("ui.admin.tenants.table.updated", "UPDATED")} - + {t("ui.admin.tenants.table.actions", "ACTIONS")} @@ -575,21 +575,18 @@ function TenantListPage() { )} - + - {t( - `domain.tenant_type.${tenant.type?.toLowerCase()}`, - tenant.type, - )} + {tenant.type} {tenant.slug} - + - + {tenant.memberCount} - + {tenant.updatedAt ? new Date(tenant.updatedAt).toLocaleString("ko-KR") : "-"} - +
- ))} -
{ + setUserFilters(nextFilters); + setSelectedUserRowKeys([]); + }} + visibleColumns={userVisibleColumns} + onVisibleColumnsChange={setUserVisibleColumns} + passwordManageTenantId={overview?.config.adminTenantId} actionLabel="선택 구성원 WORKS에 생성" actionDisabled={isCreatingUsers || createSelectedMutation.isPending} - onCreateSelected={() => + onCreateSelected={(ids) => createSelectedMutation.mutate({ resourceKind: "users", - ids: selectedUserIds, + ids, }) } /> @@ -320,16 +330,21 @@ export function TenantWorksmobilePage() { )} rows={comparisonGroups} loading={comparisonQuery.isLoading} - selectedIds={selectedGroupIds} - onSelectedIdsChange={setSelectedGroupIds} + selectedKeys={selectedGroupRowKeys} + onSelectedKeysChange={setSelectedGroupRowKeys} + filters={undefined} + onFiltersChange={undefined} + visibleColumns={groupVisibleColumns} + onVisibleColumnsChange={setGroupVisibleColumns} + passwordManageTenantId={undefined} actionLabel="선택 조직 WORKS에 생성" actionDisabled={ isCreatingGroups || createSelectedMutation.isPending } - onCreateSelected={() => + onCreateSelected={(ids) => createSelectedMutation.mutate({ resourceKind: "groups", - ids: selectedGroupIds, + ids, }) } /> @@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = { missingExternalKey: number; }; +export type WorksmobileComparisonColumnKey = + | "status" + | "baronId" + | "baron" + | "baronOrg" + | "worksmobileId" + | "externalKey" + | "worksmobileDomain" + | "worksmobile" + | "worksmobileOrg" + | "manage"; + +export type WorksmobileComparisonColumnVisibility = Record< + WorksmobileComparisonColumnKey, + boolean +>; + +const worksmobileComparisonColumnOptions: Array<{ + key: WorksmobileComparisonColumnKey; + label: string; +}> = [ + { key: "status", label: "상태" }, + { key: "baronId", label: "Baron ID" }, + { key: "baron", label: "Baron" }, + { key: "baronOrg", label: "Baron 조직" }, + { key: "worksmobileId", label: "WORKS ID" }, + { key: "externalKey", label: "external_key" }, + { key: "worksmobileDomain", label: "WORKS 도메인" }, + { key: "worksmobile", label: "WORKS" }, + { key: "worksmobileOrg", label: "WORKS 조직" }, + { key: "manage", label: "관리" }, +]; + +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 { @@ -480,6 +543,54 @@ 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", +]); + +export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) { + return ( + row.resourceType === "USER" && + immutableWorksmobileAccountEmails.has( + row.worksmobileEmail?.trim().toLowerCase() ?? "", + ) + ); +} + +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 filterWorksmobileComparisonRows( rows: WorksmobileComparisonItem[], filters: WorksmobileComparisonFilter[], @@ -519,13 +630,56 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) { 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 userFilterOptions: Array<{ value: WorksmobileComparisonFilter; label: string; }> = [ - { value: "baron_only", label: "Baron에만 있음" }, - { value: "works_only", label: "WORKS에만 있음" }, - { value: "matched", label: "양쪽에 다 있음" }, + { value: "baron_only", label: "바론에만 있음" }, + { value: "works_only", label: "웍스에만 있음" }, + { value: "matched", label: "양쪽 다 있음" }, ]; const worksmobileFilterStatuses: Record = @@ -605,8 +759,13 @@ function ComparisonTable({ title, rows, loading, - selectedIds, - onSelectedIdsChange, + selectedKeys, + onSelectedKeysChange, + filters, + onFiltersChange, + visibleColumns, + onVisibleColumnsChange, + passwordManageTenantId, actionLabel, actionDisabled, onCreateSelected, @@ -614,101 +773,238 @@ function ComparisonTable({ title: string; rows: WorksmobileComparisonItem[]; loading: boolean; - selectedIds: string[]; - onSelectedIdsChange: (ids: string[]) => void; + selectedKeys: string[]; + onSelectedKeysChange: (ids: string[]) => void; + filters?: WorksmobileComparisonFilter[]; + onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void; + visibleColumns: WorksmobileComparisonColumnVisibility; + onVisibleColumnsChange: React.Dispatch< + React.SetStateAction + >; + passwordManageTenantId?: string; actionLabel: string; actionDisabled: boolean; - onCreateSelected: () => void; + onCreateSelected: (ids: string[]) => void; }) { - const creatableIds = rows - .filter(canCreateWorksmobileRow) - .map((row) => row.baronId) - .filter((id): id is string => Boolean(id)); - const allCreatableSelected = - creatableIds.length > 0 && - creatableIds.every((id) => selectedIds.includes(id)); + const selectableKeys = rows + .filter(canSelectWorksmobileRow) + .map(getWorksmobileRowSelectionKey) + .filter(Boolean); + const selectedActionIds = getWorksmobileSelectedActionIds( + rows, + selectedKeys, + ); + const allSelectableSelected = + selectableKeys.length > 0 && + selectableKeys.every((key) => selectedKeys.includes(key)); + const visibleColumnCount = worksmobileComparisonColumnOptions.filter( + (column) => visibleColumns[column.key] !== false, + ).length; + const tableColSpan = visibleColumnCount + 1; const toggleAll = (checked: boolean | "indeterminate") => { - onSelectedIdsChange(checked === true ? creatableIds : []); + onSelectedKeysChange(checked === true ? selectableKeys : []); }; const toggleRow = ( - id: string | undefined, + row: WorksmobileComparisonItem, checked: boolean | "indeterminate", ) => { - if (!id) { + const key = getWorksmobileRowSelectionKey(row); + if (!key) { return; } if (checked === true) { - onSelectedIdsChange([...new Set([...selectedIds, id])]); + onSelectedKeysChange([...new Set([...selectedKeys, key])]); return; } - onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id)); + onSelectedKeysChange( + selectedKeys.filter((selectedKey) => selectedKey !== key), + ); + }; + + const openPasswordManage = (row: WorksmobileComparisonItem) => { + const url = buildWorksmobilePasswordManageUrl({ + tenantId: passwordManageTenantId, + domainId: row.worksmobileDomainId, + userIdNo: row.worksmobileId, + }); + if (!url) return; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + const toggleColumn = (key: WorksmobileComparisonColumnKey) => { + onVisibleColumnsChange((current) => ({ + ...current, + [key]: current[key] === false, + })); + }; + + const isColumnVisible = (key: WorksmobileComparisonColumnKey) => + visibleColumns[key] !== false; + + const toggleFilter = (filter: WorksmobileComparisonFilter) => { + if (!filters || !onFiltersChange) { + return; + } + onFiltersChange( + filters.includes(filter) + ? filters.filter((value) => value !== filter) + : [...filters, filter], + ); }; return ( -
-
-

{title}

- +
+
+
+

{title}

+ {filters && onFiltersChange && ( +
+ {userFilterOptions.map((option) => ( + + ))} +
+ )} +
+
+ + + + + + + {title} 컬럼 설정 + + 이 테이블에 표시할 비교 컬럼을 선택하세요. + + +
+ {worksmobileComparisonColumnOptions.map((column) => ( + + ))} +
+ + + + + +
+
+ +
-
-
+
+
- 상태 - - Baron ID - - - Baron - - - Baron 조직 - - - WORKS ID - - - external_key - - - WORKS 도메인 - - - WORKS - - - WORKS 조직 - + {isColumnVisible("status") && ( + 상태 + )} + {isColumnVisible("baronId") && ( + + Baron ID + + )} + {isColumnVisible("baron") && ( + + Baron + + )} + {isColumnVisible("baronOrg") && ( + + Baron 조직 + + )} + {isColumnVisible("worksmobileId") && ( + + WORKS ID + + )} + {isColumnVisible("externalKey") && ( + + external_key + + )} + {isColumnVisible("worksmobileDomain") && ( + + WORKS 도메인 + + )} + {isColumnVisible("worksmobile") && ( + + WORKS + + )} + {isColumnVisible("worksmobileOrg") && ( + + WORKS 조직 + + )} + {isColumnVisible("manage") && ( + 관리 + )} {loading && ( - + 불러오는 중... )} {!loading && rows.length === 0 && ( - + 표시할 차이가 없습니다. @@ -720,87 +1016,126 @@ function ComparisonTable({ - toggleRow(row.baronId, checked) - } + disabled={!canSelectWorksmobileRow(row)} + onCheckedChange={(checked) => toggleRow(row, checked)} /> - - - {getWorksmobileComparisonStatusLabel(row.status)} - - - - {row.baronId ?? "-"} - - -
-
{row.baronName ?? "-"}
-
- {row.baronEmail ?? ""} + {isColumnVisible("status") && ( + + + {getWorksmobileComparisonStatusLabel(row.status)} + + + )} + {isColumnVisible("baronId") && ( + + {row.baronId ?? "-"} + + )} + {isColumnVisible("baron") && ( + +
+
{row.baronName ?? "-"}
+
+ {row.baronEmail ?? ""} +
-
- - - - - - {row.worksmobileId ?? "-"} - - - {row.externalKey ?? "-"} - - - - - -
-
{formatWorksmobilePersonName(row) || "-"}
-
- {row.worksmobileEmail ?? ""} + + )} + {isColumnVisible("baronOrg") && ( + + + + )} + {isColumnVisible("worksmobileId") && ( + + {row.worksmobileId ?? "-"} + + )} + {isColumnVisible("externalKey") && ( + + {row.externalKey ?? "-"} + + )} + {isColumnVisible("worksmobileDomain") && ( + + + + )} + {isColumnVisible("worksmobile") && ( + +
+
{formatWorksmobilePersonName(row) || "-"}
+
+ {row.worksmobileEmail ?? ""} +
-
- - - - + + )} + {isColumnVisible("worksmobileOrg") && ( + + + + )} + {isColumnVisible("manage") && ( + + {row.resourceType === "USER" && ( + + )} + + )} ))} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 8f0ee7a1..d1e4b245 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -508,7 +508,9 @@ export type WorksmobileOverview = { tenant: TenantSummary; config: { enabled: boolean; + domainMappings?: Record; tokenConfigured: boolean; + adminTenantId?: string; }; recentJobs: WorksmobileOutboxItem[]; }; diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 1847ce68..0a150c9b 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -57,6 +57,7 @@ test.describe("Tenants Management", () => { }); test("should list tenants", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 700 }); const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea"; await page.route("**/api/v1/admin/tenants**", async (route) => { @@ -93,6 +94,15 @@ test.describe("Tenants Management", () => { timeout: 10000, }); await expect(page.locator("table")).toContainText(internalTenantId); + await expect(page.locator("table")).toContainText("COMPANY"); + await expect(page.locator("table")).not.toContainText("일반 기업"); + + const headerWhiteSpace = await page + .locator("table thead th") + .evaluateAll((headers) => + headers.map((header) => window.getComputedStyle(header).whiteSpace), + ); + expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true); }); test("should create a new tenant", async ({ page }) => { diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts index 0068391a..dd36795e 100644 --- a/adminfront/tests/worksmobile.spec.ts +++ b/adminfront/tests/worksmobile.spec.ts @@ -197,7 +197,7 @@ test.describe("Worksmobile tenant management", () => { await expect(page.getByText("domainMappings")).not.toBeVisible(); await expect(page.getByText("SCIM token")).not.toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); - await expect(page.getByText("박웍스")).not.toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); await expect(page.getByText("WORKS 전용 조직")).toBeVisible(); await expect(page.getByText("기술본부", { exact: true })).toBeVisible(); await expect(page.getByText("parent-tech", { exact: true })).toBeVisible(); @@ -208,47 +208,47 @@ test.describe("Worksmobile tenant management", () => { const filterButtons = page .getByRole("button", { - name: /Baron에만 있음|WORKS에만 있음|양쪽에 다 있음/, + name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/, }) .allTextContents(); await expect.poll(() => filterButtons).toEqual([ - "Baron에만 있음", - "WORKS에만 있음", - "양쪽에 다 있음", + "바론에만 있음", + "웍스에만 있음", + "양쪽 다 있음", ]); - await page.getByRole("button", { name: "WORKS에만 있음" }).click(); - await expect(page.getByText("박웍스")).toBeVisible(); + await page.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 page.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(); await expect(page.getByText("WORKS 기술기획")).toBeVisible(); await expect(page.getByText("works-team-tech")).toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); - await expect(page.getByText("박웍스")).toBeVisible(); + await expect(page.getByText("박웍스")).not.toBeVisible(); - await page.getByRole("button", { name: "Baron에만 있음" }).click(); - await expect(page.getByText("홍길동")).toHaveCount(2); - await expect(page.getByText("김누락")).not.toBeVisible(); - await expect(page.getByText("박웍스")).toBeVisible(); - - await page.getByRole("button", { name: "WORKS에만 있음" }).click(); + await page.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 page.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 expect(page.getByText("김누락")).not.toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); + await expect(page.getByText("홍길동")).not.toBeVisible(); + + await page.getByRole("button", { name: "바론에만 있음" }).click(); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("박웍스")).toBeVisible(); - await expect(page.getByText("홍길동")).toHaveCount(2); - - await page.getByRole("button", { name: "Baron에만 있음" }).click(); - await expect(page.getByText("김누락")).toBeVisible(); - await expect(page.getByText("박웍스")).not.toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); await page @@ -360,4 +360,148 @@ test.describe("Worksmobile tenant management", () => { page.getByText(/WORKS API rejected user creation/), ).toBeVisible(); }); + + test("keeps wide comparison columns inside table scroll and blocks immutable WORKS accounts", async ({ + page, + }) => { + await page.setViewportSize({ width: 900, height: 700 }); + + await page.route("**/api/v1/**", async (route) => { + const url = new URL(route.request().url()); + const method = route.request().method(); + const headers = { "Access-Control-Allow-Origin": "*" }; + + if (url.pathname.endsWith("/user/me")) { + return route.fulfill({ + json: { id: "admin-user", name: "Admin", role: "super_admin" }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id") && + method === "GET" + ) { + return route.fulfill({ + json: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") && + method === "GET" + ) { + return route.fulfill({ + json: { + tenant: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + }, + config: { + adminTenantId: "works-tenant-1", + }, + recentJobs: [], + }, + headers, + }); + } + + if ( + url.pathname.endsWith( + "/admin/tenants/hanmac-family-id/worksmobile/comparison", + ) && + method === "GET" + ) { + return route.fulfill({ + json: { + users: [ + { + resourceType: "USER", + worksmobileId: + "works-user-with-extra-long-identifier-for-scroll-check", + externalKey: "external-key-with-extra-long-identifier", + worksmobileName: "긴 WORKS 사용자", + worksmobileEmail: + "long-works-user-name-for-scroll@samaneng.com", + worksmobileDomainId: 300285955, + worksmobileDomainName: "samaneng.com", + worksmobilePrimaryOrgId: + "works-primary-org-with-extra-long-identifier", + worksmobilePrimaryOrgName: "긴 WORKS 조직", + status: "missing_in_baron", + }, + { + resourceType: "USER", + worksmobileId: "works-cyhan", + worksmobileName: "변경 불가 계정", + worksmobileEmail: "cyhan@samaneng.com", + worksmobileDomainId: 300285955, + worksmobileDomainName: "samaneng.com", + status: "missing_in_baron", + }, + ], + groups: [], + }, + headers, + }); + } + + return route.fulfill({ json: { items: [], total: 0 }, headers }); + }); + + await page.goto("/tenants/hanmac-family-id/worksmobile"); + await expect(page.getByText("긴 WORKS 사용자")).toBeVisible(); + + const userColumnButton = page + .getByRole("heading", { name: "구성원" }) + .locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]") + .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 pageOverflow = await page.evaluate(() => ({ + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + })); + expect( + Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth), + ).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1); + + const userTableScroll = await page.locator("table").first().evaluate( + (table) => { + const container = table.parentElement?.parentElement as HTMLElement; + return { + clientWidth: container.clientWidth, + overflowX: window.getComputedStyle(container).overflowX, + scrollWidth: container.scrollWidth, + }; + }, + ); + expect(userTableScroll.overflowX).toBe("auto"); + expect(userTableScroll.scrollWidth).toBeGreaterThan( + userTableScroll.clientWidth, + ); + + const immutableRow = page.getByRole("row", { + name: /cyhan@samaneng\.com/, + }); + await expect(immutableRow.getByRole("checkbox")).toBeDisabled(); + await expect( + immutableRow.getByRole("button", { name: /비밀번호 관리/ }), + ).toBeDisabled(); + }); }); diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 6b77ec12..a2f495da 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -33,6 +33,7 @@ type WorksmobileConfigSummary struct { Enabled bool `json:"enabled"` DomainMappings map[string]int64 `json:"domainMappings"` TokenConfigured bool `json:"tokenConfigured"` + AdminTenantID string `json:"adminTenantId,omitempty"` } type WorksmobileTenantOverview struct { @@ -115,6 +116,7 @@ func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID Enabled: WorksmobileEnabled(tenant.Config), DomainMappings: WorksmobileDomainMappings(tenant.Config), TokenConfigured: worksmobileDirectoryAuthConfigured(), + AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")), }, RecentJobs: jobs, }, nil diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index d2c9e1b5..4f3fe8e6 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -56,6 +56,26 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te require.Empty(t, outboxRepo.created) } +func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) { + t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1") + root := domain.Tenant{ + ID: "root-tenant", + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + } + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}}, + &fakeWorksmobileUserRepo{}, + &fakeWorksmobileOutboxRepo{}, + nil, + ) + + overview, err := service.GetTenantOverview(context.Background(), root.ID) + + require.NoError(t, err) + require.Equal(t, "works-tenant-1", overview.Config.AdminTenantID) +} + func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) { parentID := "root-tenant" root := domain.Tenant{ diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md index 07679c5a..76849f3d 100644 --- a/docs/worksmobile-directory-sync-technical-review.md +++ b/docs/worksmobile-directory-sync-technical-review.md @@ -192,6 +192,25 @@ Worksmobile 구성원 수정 API에는 PUT(`user-update-put`)과 PATCH(`user-upd - 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다. - PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다. +### 구성원 비밀번호 관리 링크 + +Baron SSO는 생성 이후 WORKS Mobile 비밀번호 값을 직접 수정하지 않습니다. 운영자가 비밀번호 수정을 요청할 때는 해당 WORKS 계정의 식별자를 이용해 WORKS Mobile 관리자 비밀번호 관리 화면을 새 창으로 엽니다. + +사용 URL: + +```text +https://auth.worksmobile.com/integrate/password/manage?usage=admin&targetUserTenantId={회사테넌트}&targetUserDomainId={회사도메인}&targetUserIdNo={변경대상works_USER_ID}&accessUrl=https://admin.worksmobile.com/assets/self-close.html +``` + +전제와 기준: + +- 브라우저 사용자는 `auth.worksmobile.com`에 관리자 권한으로 로그인되어 있어야 합니다. +- `targetUserTenantId`는 Baron tenant UUID가 아니라 WORKS Mobile 회사 tenant 식별자입니다. Baron SSO backend는 `WORKS_ADMIN_TENANT_ID` 환경 변수로 이 값을 adminfront overview에 노출합니다. +- `targetUserDomainId`는 WORKS Mobile 비교 결과의 `worksmobileDomainId`를 사용합니다. +- `targetUserIdNo`는 WORKS Mobile 비교 결과의 `worksmobileId`를 사용합니다. +- adminfront는 세 값이 모두 있을 때만 비밀번호 관리 버튼을 활성화합니다. +- 이 링크는 WORKS Mobile 관리자 화면을 여는 기능이며, Baron SSO backend에서 password 또는 `passwordConfig` 변경 API를 호출하지 않습니다. + ## 비동기 아키텍처 권장안 Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다.