From 4d77060b5d5996afba37665b2d21e729924ce845 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 11 Jun 2026 08:29:25 +0900 Subject: [PATCH] =?UTF-8?q?custom=20claim=20=EA=B6=8C=ED=95=9C=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/code_check.yml | 2 +- README.md | 24 +- .../coverage/adminTenantTabs.test.tsx | 46 ++ .../tenants/routes/TenantListPage.test.ts | 13 + .../tenants/routes/TenantListPage.tsx | 6 +- .../routes/TenantUsersPage.export.test.tsx | 121 ++++- .../tenants/routes/TenantUsersPage.tsx | 239 +++++++--- .../features/tenants/routes/tenantListView.ts | 8 + .../routes/TenantUserGroupsTab.tsx | 239 +++++++--- .../UserDetailPage.employeeNumber.test.tsx | 290 ++++++++++-- .../src/features/users/UserDetailPage.tsx | 241 +++++++--- .../src/features/users/orgChartPicker.test.ts | 45 ++ .../src/features/users/orgChartPicker.ts | 57 +++ adminfront/src/lib/adminApi.ts | 1 + adminfront/src/locales/en.toml | 1 + adminfront/src/locales/ko.toml | 1 + adminfront/src/locales/template.toml | 1 + adminfront/tests/helpers/static-adminfront.ts | 93 ++++ .../tests/tenant-member-remove-cache.spec.ts | 134 ++++++ adminfront/tests/tenants.spec.ts | 444 +++++++++++++++++- adminfront/tests/users.spec.ts | 22 +- adminfront/tests/worksmobile.spec.ts | 2 + backend/cmd/adminctl/worksmobile_sync.go | 19 +- backend/cmd/server/main.go | 1 + backend/internal/handler/auth_handler.go | 11 + backend/internal/handler/dev_handler.go | 156 ++++-- .../handler/dev_handler_rp_metadata_test.go | 18 + backend/internal/handler/dev_handler_test.go | 117 +++++ backend/internal/handler/user_handler.go | 204 +++++++- backend/internal/handler/user_handler_test.go | 320 +++++++++++++ .../service/identity_write_service.go | 79 ++++ .../service/identity_write_service_test.go | 75 +++ .../service/worksmobile_client_test.go | 6 + .../tenant-access-allowed-tenant-added.png | Bin 821520 -> 824427 bytes .../tenant-access-allowed-tenant-deleted.png | Bin 829880 -> 834144 bytes .../features/clients/ClientConsentsPage.tsx | 97 +++- .../clients/ClientGeneralPage.claims.test.tsx | 232 +++++++++ .../features/clients/ClientGeneralPage.tsx | 162 ++++--- .../src/features/clients/ClientsPage.test.tsx | 46 ++ devfront/src/features/clients/ClientsPage.tsx | 31 +- .../src/features/coverage/pageSmoke.test.tsx | 3 +- devfront/src/locales/en.toml | 15 +- devfront/src/locales/ko.toml | 15 +- devfront/src/locales/template.toml | 13 +- devfront/tests/clients.spec.ts | 37 ++ .../devfront-client-claims-cache.spec.ts | 63 +++ .../tests/devfront-clients-lifecycle.spec.ts | 41 +- devfront/tests/devfront-consents.spec.ts | 21 + devfront/tests/helpers/devfront-fixtures.ts | 4 + devfront/tests/helpers/static-devfront.ts | 93 ++++ docs/SoT_Architecture_Policy.md | 74 +-- docs/b2b2b_dynamic_provisioning_flow.md | 2 +- docs/backup-restore-design.md | 2 +- docs/custom-field-jsonb-index-policy.md | 14 +- docs/data-integrity-management.md | 2 +- ...identity-redis-mirror-policy-2026-06-09.md | 64 +-- docs/integrations-org-context-json-api.md | 2 +- docs/kratos-user-traits-field-inventory.md | 4 +- docs/rp-iam-integration-guide.md | 2 +- docs/tenant-policy.md | 9 +- docs/tenant-usergroup-policy.md | 12 +- ...-projection-visibility-audit-2026-06-08.md | 28 +- ...ory-ssot-cache-policy-update-2026-06-10.md | 134 ++++++ docs/works-only-user-recovery-2026-06-09.md | 6 +- ...smobile-directory-sync-technical-review.md | 10 +- locales/en.toml | 1 + locales/ko.toml | 1 + locales/template.toml | 1 + orgfront/src/features/orgchart/pickerTypes.ts | 1 + .../orgchart/routes/OrgPickerPage.test.tsx | 266 +++++++++++ .../orgchart/routes/OrgPickerPage.tsx | 67 ++- orgfront/tests/orgchart-picker.spec.ts | 61 +++ ...ode_check_userfront_locale_trigger_test.sh | 39 ++ .../kratos_identity_write_path_policy_test.sh | 1 + userfront-e2e/tests/auth-routing.spec.ts | 10 +- .../tests/login-performance-budget.spec.ts | 6 +- .../tests/profile-department.spec.ts | 22 +- userfront-e2e/tests/route-inventory.spec.ts | 156 +++--- userfront/pubspec.lock | 32 +- 79 files changed, 4268 insertions(+), 670 deletions(-) create mode 100644 adminfront/tests/helpers/static-adminfront.ts create mode 100644 adminfront/tests/tenant-member-remove-cache.spec.ts create mode 100644 backend/internal/service/identity_write_service.go create mode 100644 backend/internal/service/identity_write_service_test.go create mode 100644 devfront/src/features/clients/ClientGeneralPage.claims.test.tsx create mode 100644 devfront/tests/devfront-client-claims-cache.spec.ts create mode 100644 devfront/tests/helpers/static-devfront.ts create mode 100644 docs/wiki-ory-ssot-cache-policy-update-2026-06-10.md create mode 100644 orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx create mode 100644 test/code_check_userfront_locale_trigger_test.sh diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 362dbd26..5bacafda 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -131,7 +131,7 @@ jobs: global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)' front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)' - i18n_shared='^(common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' + i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' backend=false userfront=false diff --git a/README.md b/README.md index 50e459ed..6f8fce9c 100644 --- a/README.md +++ b/README.md @@ -380,21 +380,23 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비 ### SSOT 및 Redis Cache 전략 -Baron SSO는 “하나의 DB가 모든 데이터의 원본”인 구조가 아닙니다. 데이터 성격별로 원장이 다르며, Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. Redis와 PostgreSQL projection은 성능과 운영 편의를 위한 read model/cache로만 사용하고, 원장과 불일치할 수 있다는 전제를 명시합니다. +Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane이며, Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 데이터만 read model로 보관합니다. Redis는 원장 데이터와 허용된 read model의 성능 cache/mirror로만 사용합니다. + +Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis와 허용된 read model을 조합해 cursor 기반 API로 adminfront, orgfront, userfront, 외부 API에 제공합니다. #### 데이터별 원본 위치 | 데이터 | SSOT | 보조 저장소/캐시 | 비고 | | --- | --- | --- | --- | | Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror, PostgreSQL `users.id` 참조 | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. | -| 로그인 식별자 | Kratos traits, `user_login_ids` | Redis identity mirror | Kratos는 인증 식별자, PostgreSQL은 중복/정책 검증용 index입니다. | -| 사용자 이름, 이메일, 전화번호, role 기본값 | Kratos traits | PostgreSQL `users`, Redis mirror | 인증/profile 계산에 필요한 최소 identity 값만 Kratos에 유지합니다. | -| Baron 사용자 상태, soft delete, 운영 메타데이터 | PostgreSQL `users`, `users.metadata` | Redis mirror 조합 응답 | `users.deleted_at`은 Baron 운영 상태이며 Kratos identity 삭제와 같은 의미가 아닙니다. | -| 테넌트 tree, slug, 조직/부서/직무/직책 | PostgreSQL `tenants`, `users`, membership metadata | Redis/API response cache 가능 | 관계형 조직 데이터는 Kratos traits가 아니라 Backend DB가 원장입니다. | +| 로그인 식별자 | Ory Kratos traits | Redis identity mirror, `user_login_ids` read model | Kratos가 인증 식별자의 원장이고 Backend DB는 중복/정책 검증용 read model입니다. | +| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror, PostgreSQL `users` read model | 인증/profile 계산에 필요한 최소 identity 값만 Kratos에 유지합니다. | +| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Backend read model | Redis 조합 응답 cache | Ory에 같은 의미로 저장되거나 조회되지 않는 Baron 운영 데이터입니다. Kratos identity 삭제와 혼동하지 않습니다. | +| 테넌트 tree, slug, 조직/부서/직무/직책 | Ory Keto relation tuple, Backend read model | Redis/API response cache 가능 | 권한/관계 판단은 Keto가 원장입니다. Ory가 보관하거나 조회할 수 없는 조직 표시/검색 데이터만 Backend read model에 둡니다. | | 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. | | OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. | -| RP별 사용자 custom claim 값 | PostgreSQL `rp_user_metadata` | ID token/userinfo projection | RP 관리자 범위 데이터이며 전역 claim과 분리합니다. | -| 전역 사용자 custom claim 값 | PostgreSQL `users.metadata.global_custom_claims` | ID token projection | 전체 사용자 대상 claim으로 adminfront 사용자 상세에서만 관리합니다. | +| RP별 사용자 custom claim 값 | Backend read model `rp_user_metadata` | ID token/userinfo claim assembly | Ory에 저장되지 않는 RP 범위 데이터입니다. Kratos traits나 claim output을 SSOT로 취급하지 않습니다. | +| 전역 사용자 custom claim 값 | Backend read model `users.metadata.global_custom_claims` | ID token claim assembly | Ory에 저장되지 않는 운영 범위 custom 값입니다. | | WORKS Mobile mapping/outbox/job 상태 | PostgreSQL `worksmobile_*` | WORKS API 비교 응답 cache 가능 | 외부 SaaS 연동 상태이며 identity 원장이 아닙니다. | | 감사 로그/사용량 | ClickHouse, Oathkeeper/Ory 로그 | 화면별 summary cache 가능 | command와 보안 이벤트의 감사 원장입니다. | | Headless JWKS 검증 상태 | Redis `headless:jwks:*` cache | DevFront 상태 카드 | RP public key 문서 자체는 외부 `jwksUri`가 원본입니다. | @@ -403,11 +405,11 @@ Baron SSO는 “하나의 DB가 모든 데이터의 원본”인 구조가 아 #### SSOT 보장 원칙 1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다. -2. Backend는 원장 write 성공 후 원장 ID를 기준으로 재조회하고, PostgreSQL read model 또는 Redis mirror를 write-through 갱신합니다. +2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. Backend DB 갱신은 Ory에 저장되지 않거나 조회가 불가능한 read model에 한정합니다. 3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다. 4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다. -5. PostgreSQL projection은 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자를 삭제/숨김 처리하지 않습니다. -6. frontend 대량 조회는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다. +5. Backend read model이나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자를 삭제/숨김 처리하지 않습니다. +6. frontend/API 대량 조회는 Backend가 제공하는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다. 7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다. #### Redis 사용 원칙 @@ -417,7 +419,7 @@ Redis는 원장이 아니라 cache/mirror 계층입니다. Redis 데이터 유 | Redis 데이터 | 역할 | TTL/보존 정책 | 장애 시 처리 | | --- | --- | --- | --- | | `identity:mirror:{identityID}` | Kratos identity summary 단건 cache | 장기 mirror. refresh 상태와 함께 운영 | Kratos `GetIdentity` fallback 후 write-through | -| `identity:index:*` | identity 목록/검색 cursor index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh | +| `identity:index:*` | Backend cursor API용 identity 목록/검색 index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh | | `identity:mirror:state` | mirror 상태, count, last error | 영구 상태 key | adminfront에서 경고 표시 | | `headless:jwks:*` | RP headless login JWKS cache | JWKS TTL과 prefetch 정책 | kid miss/검증 실패/TTL 만료 시 재조회 | | login/verification/pending 계열 key | 인증 흐름의 단기 상태 | 짧은 TTL 필수 | 만료 또는 유실 시 사용자가 흐름 재시작 | diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx index 722494d9..6b3d0e16 100644 --- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -15,6 +15,7 @@ const exportUsersCSVMock = vi.hoisted(() => filename: "users_export_20260609.csv", })), ); +const bulkUpdateUsersMock = vi.hoisted(() => vi.fn(async () => ({ results: [] }))); const tenants = [ { @@ -109,6 +110,7 @@ vi.mock("../../lib/adminApi", () => ({ })), updateTenant: vi.fn(async () => tenants[2]), updateUser: vi.fn(async () => users[2]), + bulkUpdateUsers: bulkUpdateUsersMock, exportTenantsCSV: vi.fn(async () => ({ blob: new Blob(["name,slug"]), filename: "tenants.csv", @@ -193,4 +195,48 @@ describe("admin tenant tab coverage smoke", () => { expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false); }); }); + + it("queues searched users and bulk adds them to the selected organization", async () => { + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/organization", + ); + + expect(await screen.findByText("Member User")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /멤버 추가/ })); + fireEvent.change(screen.getByTestId("tenant-org-member-search-input"), { + target: { value: "user" }, + }); + fireEvent.click(screen.getByTestId("tenant-org-member-search-btn")); + + fireEvent.click( + await screen.findByTestId("tenant-org-member-search-result-user-owner"), + ); + fireEvent.click( + await screen.findByTestId("tenant-org-member-search-result-user-admin"), + ); + + expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent( + "Owner User", + ); + expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent( + "Admin User", + ); + + fireEvent.click(screen.getByTestId("tenant-org-member-add-submit-btn")); + + await waitFor(() => { + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["user-owner", "user-admin"], + tenantSlug: "gpdtdc", + isAddTenant: true, + }); + }); + }); }); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.test.ts b/adminfront/src/features/tenants/routes/TenantListPage.test.ts index 7c10f5af..34597320 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantListPage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TenantSummary } from "../../../lib/adminApi"; import { + filterTenantViewRowsBySearch, filterTenantsByScope, getTenantSearchMatchIds, getTenantViewRows, @@ -97,4 +98,16 @@ describe("TenantListPage tenant list helpers", () => { ]); expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]); }); + + it("filters displayed tenant rows to direct matches only", () => { + const treeRows = getTenantViewRows( + tenants.filter((item) => item.id !== "company-2"), + "tree", + "", + true, + ); + + expect(filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id)) + .toEqual(["team-1"]); + }); }); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 40fa5418..8789299a 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -106,6 +106,7 @@ import { type TenantImportResolution, } from "../utils/tenantCsvImport"; import { + filterTenantViewRowsBySearch, filterTenantsByScope, getTenantSearchMatchIds, getTenantViewRows, @@ -1664,11 +1665,12 @@ const TenantHierarchyView: React.FC<{ const flattenedRows = React.useMemo(() => { if (viewMode === "table") { - return sortItems( + const rows = sortItems( getTenantViewRows(tenants, "table", scopeTenantId, !!search), sortConfig, tenantSortResolvers, ); + return filterTenantViewRowsBySearch(rows, search); } const result: TenantViewRow[] = []; @@ -1689,7 +1691,7 @@ const TenantHierarchyView: React.FC<{ } }; collect(subTree, 0); - return result; + return filterTenantViewRowsBySearch(result, search); }, [ expandedIds, scopeTenantId, diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.export.test.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.export.test.tsx index 70b6cf16..90c75c30 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.export.test.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.export.test.tsx @@ -7,6 +7,7 @@ import TenantUsersPage from "./TenantUsersPage"; const exportUsersCSVMock = vi.hoisted(() => vi.fn()); const updateUserMock = vi.hoisted(() => vi.fn()); +const bulkUpdateUsersMock = vi.hoisted(() => vi.fn()); const fetchUsersMock = vi.hoisted(() => vi.fn()); vi.mock("../../../lib/i18n", () => createI18nMock()); @@ -18,6 +19,7 @@ vi.mock("../../../lib/adminApi", () => ({ slug: "tech-planning", })), fetchUsers: fetchUsersMock, + bulkUpdateUsers: bulkUpdateUsersMock, exportUsersCSV: exportUsersCSVMock, updateUser: updateUserMock, })); @@ -26,8 +28,7 @@ function renderTenantUsersPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - - return render( + const result = render( @@ -39,12 +40,15 @@ function renderTenantUsersPage() { , ); + + return { ...result, queryClient }; } describe("TenantUsersPage export", () => { beforeEach(() => { exportUsersCSVMock.mockReset(); updateUserMock.mockReset(); + bulkUpdateUsersMock.mockReset(); fetchUsersMock.mockReset(); fetchUsersMock.mockResolvedValue({ items: [ @@ -64,10 +68,12 @@ describe("TenantUsersPage export", () => { }), filename: "users_export_20260609.csv", }); + updateUserMock.mockResolvedValue({}); vi.spyOn(window.URL, "createObjectURL").mockReturnValue( "blob:tenant-users-export", ); vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {}); + bulkUpdateUsersMock.mockResolvedValue({ results: [] }); }); it("exports only the currently opened tenant users by tenant slug", async () => { @@ -135,14 +141,121 @@ describe("TenantUsersPage export", () => { fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn")); await waitFor(() => { - expect(updateUserMock).toHaveBeenCalledWith("user-2", { + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["user-2", "user-3"], tenantSlug: "tech-planning", isAddTenant: true, }); - expect(updateUserMock).toHaveBeenCalledWith("user-3", { + }); + expect(updateUserMock).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ isAddTenant: true }), + ); + }); + + it("queues orgfront multi picker users and adds them with one bulk request", async () => { + fetchUsersMock + .mockResolvedValueOnce({ + items: [ + { + id: "existing-user", + name: "Existing", + email: "existing@example.com", + role: "user", + status: "active", + }, + ], + total: 1, + }) + .mockResolvedValue({ items: [], total: 0 }); + + renderTenantUsersPage(); + + const addButton = await screen.findByTestId( + "tenant-member-add-existing-btn", + ); + await waitFor(() => expect(addButton).not.toBeDisabled()); + fireEvent.click(addButton); + + const picker = await screen.findByTitle("조직도에서 구성원 선택"); + expect(decodeURIComponent(picker.getAttribute("src") ?? "")).toContain( + "/embed/picker?mode=multiple&select=user", + ); + + fireEvent( + window, + new MessageEvent("message", { + data: { + type: "orgfront:picker:confirm", + payload: { + mode: "multiple", + selections: [ + { type: "tenant", id: "team-1", name: "플랫폼팀" }, + { + type: "user", + id: "picked-user-1", + name: "Picked One", + email: "picked1@example.com", + }, + { + type: "user", + id: "picked-user-2", + name: "Picked Two", + }, + { + type: "user", + id: "existing-user", + name: "Existing", + email: "existing@example.com", + }, + ], + }, + }, + }), + ); + + expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent( + "Picked One", + ); + expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent( + "Picked Two", + ); + expect(screen.getByTestId("tenant-member-add-queue")).not.toHaveTextContent( + "Existing", + ); + + fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn")); + + await waitFor(() => { + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["picked-user-1", "picked-user-2"], tenantSlug: "tech-planning", isAddTenant: true, }); }); }); + + it("removes a member from the tenant and invalidates the user detail cache", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const { queryClient } = renderTenantUsersPage(); + queryClient.setQueryData(["user", "user-1"], { + id: "user-1", + name: "Alice", + }); + + await screen.findByText("Alice"); + + fireEvent.click(screen.getByTestId("tenant-member-remove-user-1")); + + await waitFor(() => { + expect(updateUserMock).toHaveBeenCalledWith("user-1", { + tenantSlug: "tech-planning", + isRemoveTenant: true, + }); + }); + expect(queryClient.getQueryState(["user", "user-1"])?.isInvalidated).toBe( + true, + ); + confirmSpy.mockRestore(); + }); }); diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index b8db9cb9..effa5306 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -40,6 +40,7 @@ import { } from "../../../components/ui/table"; import { toast } from "../../../components/ui/use-toast"; import { + bulkUpdateUsers, exportUsersCSV, fetchTenant, fetchUsers, @@ -47,6 +48,10 @@ import { updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { + buildAuthenticatedOrgChartUserMultiPickerUrl, + parseOrgChartUserSelections, +} from "../../users/orgChartPicker"; function TenantUsersPage() { const params = useParams<{ tenantId: string }>(); @@ -56,6 +61,13 @@ function TenantUsersPage() { const [addMembersOpen, setAddMembersOpen] = React.useState(false); const [memberSearch, setMemberSearch] = React.useState(""); const [queuedMembers, setQueuedMembers] = React.useState([]); + const orgChartMemberPickerUrl = React.useMemo( + () => + buildAuthenticatedOrgChartUserMultiPickerUrl( + import.meta.env.ORGFRONT_URL, + ), + [], + ); // 테넌트의 슬러그(tenantSlug)를 먼저 가져옴 const tenantQuery = useQuery({ @@ -103,7 +115,7 @@ function TenantUsersPage() { const removeTenantMutation = useMutation({ mutationFn: ({ userId, slug }: { userId: string; slug: string }) => updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }), - onSuccess: () => { + onSuccess: (_result, variables) => { toast.success( t( "msg.admin.tenants.members.remove_success", @@ -111,6 +123,8 @@ function TenantUsersPage() { ), ); usersQuery.refetch(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["user", variables.userId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); }, onError: (err: AxiosError<{ error?: string }>) => { @@ -124,11 +138,11 @@ function TenantUsersPage() { const addMembersMutation = useMutation({ mutationFn: async (members: UserSummary[]) => { if (!tenantSlug || members.length === 0) return; - await Promise.all( - members.map((member) => - updateUser(member.id, { tenantSlug, isAddTenant: true }), - ), - ); + await bulkUpdateUsers({ + userIds: members.map((member) => member.id), + tenantSlug, + isAddTenant: true, + }); }, onSuccess: () => { const count = queuedMembers.length; @@ -179,11 +193,27 @@ function TenantUsersPage() { ); const searchResults = memberSearchQuery.data?.items ?? []; + const queueMembers = React.useCallback( + (members: UserSummary[]) => { + setQueuedMembers((current) => { + const blockedIds = new Set([ + ...existingUserIds, + ...current.map((member) => member.id), + ]); + const next = [...current]; + for (const member of members) { + if (blockedIds.has(member.id)) continue; + blockedIds.add(member.id); + next.push(member); + } + return next; + }); + }, + [existingUserIds], + ); + const queueMember = (member: UserSummary) => { - if (existingUserIds.has(member.id) || queuedUserIds.has(member.id)) { - return; - } - setQueuedMembers((current) => [...current, member]); + queueMembers([member]); }; const removeQueuedMember = (memberId: string) => { @@ -192,6 +222,30 @@ function TenantUsersPage() { ); }; + React.useEffect(() => { + if (!addMembersOpen) return; + + const onMessage = (event: MessageEvent) => { + const selections = parseOrgChartUserSelections(event.data); + if (selections.length === 0) return; + + queueMembers( + selections.map((selection) => ({ + id: selection.id, + name: selection.name, + email: selection.email, + role: "user", + status: "active", + createdAt: "", + updatedAt: "", + })), + ); + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [addMembersOpen, queueMembers]); + return ( @@ -244,7 +298,7 @@ function TenantUsersPage() { - + {t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")} @@ -256,73 +310,86 @@ function TenantUsersPage() { )} -
-
- - setMemberSearch(event.target.value)} - className="h-9 pl-9" - placeholder={t( - "ui.admin.tenants.members.search_placeholder", - "이름 또는 이메일 검색", - )} - data-testid="tenant-member-search-input" - /> -
-
-
- {memberSearchTerm.length < 2 ? ( -
- {t( - "ui.admin.tenants.members.search_min_length", - "두 글자 이상 입력하세요.", - )} -
- ) : memberSearchQuery.isFetching ? ( -
- - {t("ui.common.searching", "검색 중...")} -
- ) : searchResults.length === 0 ? ( -
- {t("ui.common.no_results", "검색 결과가 없습니다.")} -
- ) : ( -
- {searchResults.map((user) => { - const disabled = - existingUserIds.has(user.id) || - queuedUserIds.has(user.id); - return ( - - ); - })} -
- )} + + + ); + })} +
+ )} +
+
+