From 5e7b7b878cb65014a759ad7c283c80b0f80c9078 Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 13 May 2026 18:05:51 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20cursor=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1.=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20metadata=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/package-lock.json | 28 + adminfront/package.json | 1 + adminfront/src/app/routes.test.tsx | 22 + adminfront/src/app/routes.tsx | 52 +- .../src/components/layout/AppLayout.tsx | 86 +-- .../features/api-keys/ApiKeyCreatePage.tsx | 55 +- .../features/api-keys/ApiKeyListPage.test.tsx | 105 ++++ .../src/features/api-keys/ApiKeyListPage.tsx | 273 +++++++++- .../src/features/api-keys/apiKeyScopes.ts | 59 +++ adminfront/src/features/auth/AuthGuard.tsx | 41 ++ .../overview/GlobalOverviewPage.test.tsx | 2 +- .../features/overview/GlobalOverviewPage.tsx | 4 +- .../tenants/routes/TenantCreatePage.tsx | 6 +- .../tenants/routes/TenantListPage.tsx | 330 ++++++++---- .../tenants/routes/TenantProfilePage.tsx | 4 +- .../tenants/routes/TenantSubTenantsPage.tsx | 4 +- .../routes/GlobalUserGroupListPage.tsx | 4 +- .../routes/TenantUserGroupsTab.tsx | 4 +- .../routes/UserGroupDetailPage.tsx | 4 +- .../src/features/users/UserCreatePage.tsx | 62 +-- .../src/features/users/UserDetailPage.tsx | 90 ++-- .../src/features/users/UserListPage.tsx | 28 +- .../components/UserBulkMoveGroupModal.tsx | 6 +- .../users/components/UserBulkUploadModal.tsx | 4 +- .../src/features/users/orgChartPicker.test.ts | 62 +++ .../src/features/users/orgChartPicker.ts | 37 +- .../users/utils/personalTenant.test.ts | 37 ++ .../features/users/utils/personalTenant.ts | 34 ++ adminfront/src/lib/adminApi.test.ts | 77 +++ adminfront/src/lib/adminApi.ts | 124 +++-- adminfront/src/lib/apiClient.ts | 14 +- adminfront/src/lib/cursorFetch.test.ts | 71 +++ adminfront/src/lib/loginRedirectGuard.test.ts | 37 ++ adminfront/tests/bulk_actions.spec.ts | 44 ++ adminfront/tests/tenants.spec.ts | 191 +++++++ adminfront/tests/users.spec.ts | 17 +- backend/cmd/server/main.go | 2 + backend/cmd/server/openapi_static_test.go | 2 + backend/docs/openapi.yaml | 127 +++-- backend/internal/bootstrap/bootstrap.go | 5 + .../bootstrap/user_metadata_sanitize.go | 34 ++ .../bootstrap/user_metadata_sanitize_test.go | 157 ++++++ backend/internal/handler/api_key_handler.go | 197 ++++++- .../internal/handler/api_key_handler_test.go | 74 +++ .../handler/auth_handler_async_test.go | 2 +- backend/internal/handler/dev_handler.go | 131 ++++- backend/internal/handler/tenant_handler.go | 493 ++++++++++++++---- .../internal/handler/tenant_handler_test.go | 376 ++++++++++++- backend/internal/handler/user_handler.go | 140 ++++- backend/internal/handler/user_handler_test.go | 240 ++++++++- backend/internal/pagination/cursor.go | 103 ++++ backend/internal/pagination/cursor_test.go | 55 ++ .../internal/repository/tenant_repository.go | 17 +- .../repository/tenant_repository_test.go | 28 + .../repository/user_membership_maintenance.go | 56 ++ .../user_membership_maintenance_test.go | 63 +++ .../internal/repository/user_repository.go | 9 +- .../internal/service/tenant_service_test.go | 2 +- .../service/user_group_service_test.go | 2 +- .../service/worksmobile_sync_service_test.go | 2 +- common/core/auth/index.ts | 24 + common/core/pagination/cursorFetch.ts | 82 +++ common/core/pagination/cursorFetch.worker.ts | 43 ++ common/core/pagination/cursorFetchCore.ts | 106 ++++ common/core/pagination/index.ts | 6 + deploy/templates/docker-compose.yaml | 6 +- devfront/src/components/layout/AppLayout.tsx | 89 ++-- devfront/src/lib/apiClient.ts | 14 +- docker/staging_pull_compose.template.yaml | 8 +- docs/integrations-org-context-json-api.md | 71 ++- docs/kratos-user-traits-field-inventory.md | 82 +++ docs/tenant-maintenance-procedures.md | 142 +++++ docs/tenant-visibility-exposure-policy.md | 59 +++ orgfront/src/components/layout/AppLayout.tsx | 100 ++-- .../features/orgchart/routes/OrgChartPage.tsx | 4 +- .../orgchart/routes/OrgPickerPage.tsx | 4 +- orgfront/src/lib/adminApi.test.ts | 77 +++ orgfront/src/lib/adminApi.ts | 90 ++-- orgfront/src/lib/apiClient.ts | 14 +- orgfront/src/lib/sessionSliding.ts | 2 +- scripts/clear_orphan_tenant_memberships.sh | 87 ++++ .../clear_orphan_user_tenant_memberships.sql | 67 +++ scripts/sanitize_baron_user_metadata.sql | 8 + test/kratos_identity_schema_policy_test.sh | 15 + test/staging_frontend_deploy_policy_test.sh | 6 + 85 files changed, 4808 insertions(+), 734 deletions(-) create mode 100644 adminfront/src/features/api-keys/ApiKeyListPage.test.tsx create mode 100644 adminfront/src/features/api-keys/apiKeyScopes.ts create mode 100644 adminfront/src/features/auth/AuthGuard.tsx create mode 100644 adminfront/src/features/users/utils/personalTenant.test.ts create mode 100644 adminfront/src/features/users/utils/personalTenant.ts create mode 100644 adminfront/src/lib/adminApi.test.ts create mode 100644 adminfront/src/lib/cursorFetch.test.ts create mode 100644 adminfront/src/lib/loginRedirectGuard.test.ts create mode 100644 backend/internal/bootstrap/user_metadata_sanitize.go create mode 100644 backend/internal/bootstrap/user_metadata_sanitize_test.go create mode 100644 backend/internal/pagination/cursor.go create mode 100644 backend/internal/pagination/cursor_test.go create mode 100644 backend/internal/repository/user_membership_maintenance.go create mode 100644 backend/internal/repository/user_membership_maintenance_test.go create mode 100644 common/core/pagination/cursorFetch.ts create mode 100644 common/core/pagination/cursorFetch.worker.ts create mode 100644 common/core/pagination/cursorFetchCore.ts create mode 100644 common/core/pagination/index.ts create mode 100644 docs/kratos-user-traits-field-inventory.md create mode 100644 docs/tenant-maintenance-procedures.md create mode 100644 docs/tenant-visibility-exposure-policy.md create mode 100644 orgfront/src/lib/adminApi.test.ts create mode 100755 scripts/clear_orphan_tenant_memberships.sh create mode 100644 scripts/clear_orphan_user_tenant_memberships.sql create mode 100644 scripts/sanitize_baron_user_metadata.sql create mode 100644 test/kratos_identity_schema_policy_test.sh diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json index a1198055..59bfe9e3 100644 --- a/adminfront/package-lock.json +++ b/adminfront/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-switch": "^1.1.2", "@tanstack/react-query": "^5.66.8", "@tanstack/react-query-devtools": "^5.66.8", + "@tanstack/react-virtual": "^3.13.24", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -3290,6 +3291,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/adminfront/package.json b/adminfront/package.json index 742cba61..b687f930 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-switch": "^1.1.2", "@tanstack/react-query": "^5.66.8", "@tanstack/react-query-devtools": "^5.66.8", + "@tanstack/react-virtual": "^3.13.24", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/adminfront/src/app/routes.test.tsx b/adminfront/src/app/routes.test.tsx index 86782fde..08d10367 100644 --- a/adminfront/src/app/routes.test.tsx +++ b/adminfront/src/app/routes.test.tsx @@ -21,4 +21,26 @@ describe("admin routes", () => { expect(matches?.at(-1)?.route.path).toBe("system/projections/users"); }); + + it("keeps protected admin pages behind an auth guard before mounting the layout", () => { + const rootRoute = adminRoutes.find((route) => route.path === "/"); + const protectedShellRoute = rootRoute?.children?.[0]; + + expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard"); + expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout"); + expect(protectedShellRoute?.children?.at(0)?.index).toBe(true); + }); }); + +function getRouteElementName(element: unknown) { + if ( + typeof element === "object" && + element !== null && + "type" in element && + typeof element.type === "function" + ) { + return element.type.name; + } + + return undefined; +} diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 9e1289a5..df3df402 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -5,6 +5,7 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage"; import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage"; +import AuthGuard from "../features/auth/AuthGuard"; import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; @@ -34,34 +35,39 @@ export const adminRoutes: RouteObject[] = [ }, { path: "/", - element: , + element: , children: [ - { index: true, element: }, - { path: "audit-logs", element: }, - { path: "auth", element: }, - { path: "users", element: }, - { path: "users/new", element: }, - { path: "users/:id", element: }, - { path: "tenants", element: }, - { path: "tenants/new", element: }, { - path: "tenants/:tenantId", - element: , + element: , children: [ - { index: true, element: }, - { path: "permissions", element: }, - { path: "organization", element: }, - { path: "schema", element: }, - { path: "worksmobile", element: }, + { index: true, element: }, + { path: "audit-logs", element: }, + { path: "auth", element: }, + { path: "users", element: }, + { path: "users/new", element: }, + { path: "users/:id", element: }, + { path: "tenants", element: }, + { path: "tenants/new", element: }, + { + path: "tenants/:tenantId", + element: , + children: [ + { index: true, element: }, + { path: "permissions", element: }, + { path: "organization", element: }, + { path: "schema", element: }, + { path: "worksmobile", element: }, + ], + }, + { + path: "tenants/:tenantId/organization/:id", + element: , + }, + { path: "api-keys", element: }, + { path: "api-keys/new", element: }, + { path: "system/projections/users", element: }, ], }, - { - path: "tenants/:tenantId/organization/:id", - element: , - }, - { path: "api-keys", element: }, - { path: "api-keys/new", element: }, - { path: "system/projections/users", element: }, ], }, ]; diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 3a9b7895..7f8d894a 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { + type ShellTranslator, applyShellTheme, buildShellProfileSummary, buildShellSessionStatus, @@ -53,6 +54,48 @@ const staticNavItems: NavItem[] = [ { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, ]; +type SessionStatusProps = { + expiresAtSec?: number | null; + t: ShellTranslator; +}; + +function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => { + setNowMs(Date.now()); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, []); + + return buildShellSessionStatus({ expiresAtSec, nowMs, t }); +} + +function SessionStatusBadge(props: SessionStatusProps) { + const sessionStatus = useSessionStatus(props); + + return ( + + {sessionStatus.text} + + ); +} + +function SessionStatusText(props: SessionStatusProps) { + const sessionStatus = useSessionStatus(props); + + return <>{sessionStatus.text}; +} + function AppLayout() { const auth = useAuth(); const location = useLocation(); @@ -76,17 +119,6 @@ function AppLayout() { const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( readShellSessionExpiryEnabled, ); - const [nowMs, setNowMs] = useState(() => Date.now()); - - useEffect(() => { - const timer = window.setInterval(() => { - setNowMs(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, []); - const { data: profile, isLoading: isProfileLoading, @@ -396,12 +428,6 @@ function AppLayout() { fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"), }); const profileRoleKey = mockRoleOverride || profile?.role || "user"; - const sessionStatus = buildShellSessionStatus({ - expiresAtSec: auth.user?.expires_at, - nowMs, - t, - }); - const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; @@ -525,14 +551,10 @@ function AppLayout() { : t("ui.common.theme_dark", "Dark")} {isSessionExpiryEnabled ? ( - - {sessionStatus.text} - + ) : null}
+
+ + + +
))} @@ -207,6 +327,137 @@ function ApiKeyListPage() { + + setEditingKey(null)} + > + + + + {t("ui.admin.api_keys.list.edit_scopes", "권한 수정")} + + + {editingKey + ? t( + "msg.admin.api_keys.list.edit_scopes_desc", + "{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.", + { clientId: editingKey.client_id }, + ) + : null} + + +
+ {AVAILABLE_API_KEY_SCOPES.map((scope) => { + const isSelected = draftScopes.includes(scope.id); + return ( + + ); + })} +
+ {draftScopes.length === 0 && ( +

+ {t( + "msg.admin.api_keys.create.scope_required", + "최소 하나 이상의 권한을 선택해야 합니다.", + )} +

+ )} + + + + +
+
+ + setRotatedSecret(null)} + > + + + + {t( + "ui.admin.api_keys.list.rotate_secret_done", + "Secret 재발급 완료", + )} + + + {t( + "msg.admin.api_keys.list.rotate_secret_notice", + "새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.", + )} + + + {rotatedSecret && ( +
+
+

+ CLIENT ID +

+ + {rotatedSecret.key.client_id} + +
+
+

+ X-Baron-Key-Secret +

+
+ + +
+
+
+ )} + + + +
+
); } diff --git a/adminfront/src/features/api-keys/apiKeyScopes.ts b/adminfront/src/features/api-keys/apiKeyScopes.ts new file mode 100644 index 00000000..2acfa2f4 --- /dev/null +++ b/adminfront/src/features/api-keys/apiKeyScopes.ts @@ -0,0 +1,59 @@ +export type ApiKeyScopeOption = { + id: string; + labelKey: string; + labelFallback: string; + descKey: string; + descFallback: string; +}; + +export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [ + { + id: "audit:read", + labelKey: "ui.admin.api_keys.scopes.audit_read.title", + labelFallback: "감사 로그 조회", + descKey: "msg.admin.api_keys.scopes.audit_read.desc", + descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.", + }, + { + id: "audit:write", + labelKey: "ui.admin.api_keys.scopes.audit_write.title", + labelFallback: "감사 로그 생성", + descKey: "msg.admin.api_keys.scopes.audit_write.desc", + descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.", + }, + { + id: "user:read", + labelKey: "ui.admin.api_keys.scopes.user_read.title", + labelFallback: "사용자 조회", + descKey: "msg.admin.api_keys.scopes.user_read.desc", + descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.", + }, + { + id: "user:write", + labelKey: "ui.admin.api_keys.scopes.user_write.title", + labelFallback: "사용자 관리", + descKey: "msg.admin.api_keys.scopes.user_write.desc", + descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.", + }, + { + id: "tenant:read", + labelKey: "ui.admin.api_keys.scopes.tenant_read.title", + labelFallback: "테넌트 조회", + descKey: "msg.admin.api_keys.scopes.tenant_read.desc", + descFallback: "등록된 모든 조직 정보를 조회합니다.", + }, + { + id: "tenant:write", + labelKey: "ui.admin.api_keys.scopes.tenant_write.title", + labelFallback: "테넌트 관리", + descKey: "msg.admin.api_keys.scopes.tenant_write.desc", + descFallback: "테넌트 정보를 직접 제어합니다.", + }, + { + id: "org-context:read", + labelKey: "ui.admin.api_keys.scopes.org_context_read.title", + labelFallback: "조직 Context 조회", + descKey: "msg.admin.api_keys.scopes.org_context_read.desc", + descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.", + }, +]; diff --git a/adminfront/src/features/auth/AuthGuard.tsx b/adminfront/src/features/auth/AuthGuard.tsx new file mode 100644 index 00000000..d809a7de --- /dev/null +++ b/adminfront/src/features/auth/AuthGuard.tsx @@ -0,0 +1,41 @@ +import { useAuth } from "react-oidc-context"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; + +export default function AuthGuard() { + const auth = useAuth(); + const location = useLocation(); + const isTest = + (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) + ._IS_TEST_MODE === true; + + if (isTest) { + return ; + } + + if (auth.isLoading || auth.activeNavigator) { + return
Loading...
; + } + + if (auth.error) { + return ( +
+
+

인증 오류

+

{auth.error.message}

+
+
+ ); + } + + if (!auth.isAuthenticated) { + const returnTo = `${location.pathname}${location.search}${location.hash}`; + return ( + + ); + } + + return ; +} diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx index fbf9f83a..80e33caf 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -14,7 +14,7 @@ vi.mock("../../lib/adminApi", () => ({ oidcClients: 3, auditEvents24h: 18, })), - fetchTenants: vi.fn(async () => ({ + fetchAllTenants: vi.fn(async () => ({ items: [ { id: "company-1", diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 6f1be04f..d3c4dad7 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -14,7 +14,7 @@ import { type TenantSummary, fetchAdminOverviewStats, fetchAdminRPUsageDaily, - fetchTenants, + fetchAllTenants, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; @@ -342,7 +342,7 @@ function GlobalOverviewPage() { }); const tenantsQuery = useQuery({ queryKey: ["admin-overview-tenant-options"], - queryFn: () => fetchTenants(1000, 0), + queryFn: () => fetchAllTenants(), retry: false, }); const tenantOptions = useMemo(() => { diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index cdf60ee1..782a218c 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -14,7 +14,7 @@ import { import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { Textarea } from "../../../components/ui/textarea"; -import { createTenant, fetchTenants } from "../../../lib/adminApi"; +import { createTenant, fetchAllTenants } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { DomainTagInput } from "../components/DomainTagInput"; import { ParentTenantSelector } from "../components/ParentTenantSelector"; @@ -47,8 +47,8 @@ function TenantCreatePage() { ); const parentQuery = useQuery({ - queryKey: ["tenants", { limit: 1000 }], - queryFn: () => fetchTenants(1000, 0), + queryKey: ["tenants", "all"], + queryFn: () => fetchAllTenants(), }); const tenants = parentQuery.data?.items ?? []; const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index fb06c7a8..23b6f609 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,4 +1,5 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { ArrowDown, @@ -74,6 +75,10 @@ import { } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; +import { + filterNonHanmacFamilyTenants, + isHanmacFamilyUser, +} from "../../users/orgChartPicker"; import { isSeedTenant } from "../utils/protectedTenants"; import { type TenantImportPreviewRow, @@ -87,8 +92,14 @@ import { const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; +const tenantPageSize = 500; +const tenantVirtualizationThreshold = 250; +const tenantEstimatedRowHeight = 73; +const tenantLoadAheadPx = 360; +const tenantLoadAheadRows = 30; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; +type TenantListRow = TenantSummary & { recursiveMemberCount: number }; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -245,6 +256,7 @@ function TenantListPage() { Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); + const tenantTableScrollRef = React.useRef(null); const { data: profile } = useQuery({ queryKey: ["me"], @@ -264,9 +276,18 @@ function TenantListPage() { } }, [profile, navigate]); - const query = useQuery({ - queryKey: ["tenants", { limit: 1000, offset: 0 }], - queryFn: () => fetchTenants(1000, 0), + const query = useInfiniteQuery({ + queryKey: ["tenants", "lazy"], + queryFn: ({ pageParam }) => + fetchTenants( + tenantPageSize, + 0, + undefined, + pageParam ? pageParam : undefined, + ), + initialPageParam: "", + getNextPageParam: (lastPage) => + lastPage.nextCursor || lastPage.next_cursor || undefined, enabled: profile?.role === "super_admin" || (profile?.role === "tenant_admin" && @@ -364,7 +385,28 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const allTenants = query.data?.items ?? []; + const tenantPages = query.data?.pages ?? []; + const rawTenants = tenantPages.flatMap((page) => page.items); + const tenantTotal = tenantPages[0]?.total ?? 0; + const hanmacFamilyTenantId = React.useMemo(() => { + const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; + if (typeof envTenantId === "string" && envTenantId.trim()) { + return envTenantId.trim(); + } + return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id; + }, [rawTenants]); + const allTenants = React.useMemo(() => { + if (profile?.role === "super_admin") { + return rawTenants; + } + if ( + profile && + isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId) + ) { + return rawTenants; + } + return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId); + }, [hanmacFamilyTenantId, profile, rawTenants]); const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); const tenantSortResolvers = React.useMemo< @@ -414,6 +456,56 @@ function TenantListPage() { return sortItems(enriched, sortConfig, tenantSortResolvers); }, [allTenants, search, sortConfig, tenantSortResolvers]); + const shouldVirtualizeTenants = + tenants.length >= tenantVirtualizationThreshold; + const tenantRowVirtualizer = useVirtualizer({ + count: tenants.length, + getScrollElement: () => tenantTableScrollRef.current, + estimateSize: () => tenantEstimatedRowHeight, + overscan: 12, + enabled: shouldVirtualizeTenants, + }); + const virtualTenantRows = shouldVirtualizeTenants + ? tenantRowVirtualizer.getVirtualItems() + : []; + const lastVirtualTenantIndex = + virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1; + + const fetchNextTenantPage = React.useCallback(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); + + const handleTenantTableScroll = React.useCallback( + (event: React.UIEvent) => { + const scrollElement = event.currentTarget; + const distanceToEnd = + scrollElement.scrollHeight - + scrollElement.scrollTop - + scrollElement.clientHeight; + if (distanceToEnd <= tenantLoadAheadPx) { + fetchNextTenantPage(); + } + }, + [fetchNextTenantPage], + ); + + React.useEffect(() => { + if ( + !shouldVirtualizeTenants || + lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows + ) { + return; + } + fetchNextTenantPage(); + }, [ + fetchNextTenantPage, + lastVirtualTenantIndex, + shouldVirtualizeTenants, + tenants.length, + ]); + const requestSort = (key: TenantSortKey) => { setSortConfig((current) => toggleSort(current, key)); }; @@ -600,6 +692,96 @@ function TenantListPage() { deleteMutation.mutate(tenantId); }; + const renderTenantRow = ( + tenant: TenantListRow, + options?: { + style?: React.CSSProperties; + virtualIndex?: number; + }, + ) => ( + + + {isSeedTenant(tenant) ? ( + + ) : ( + handleSelect(tenant, !!checked)} + /> + )} + + + {tenant.id} + + +
+ + {tenant.name} + + {isSeedTenant(tenant) && ( + + {t("ui.admin.tenants.seed_badge", "초기 설정")} + + )} +
+
+ + + {tenant.type} + + + {tenant.slug} + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + + {tenant.recursiveMemberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString("ko-KR") + : "-"} + + + + +
+ ); + return (
@@ -728,7 +910,7 @@ function TenantListPage() { "msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { - count: query.data?.total ?? 0, + count: tenantTotal, }, )} @@ -771,7 +953,12 @@ function TenantListPage() { className="flex-1 flex flex-col min-h-0 m-0" >
-
+
@@ -855,7 +1042,18 @@ function TenantListPage() { - + {query.isLoading && ( @@ -876,102 +1074,26 @@ function TenantListPage() { )} - {tenants.map((tenant) => ( - - - {isSeedTenant(tenant) ? ( - - ) : ( - - handleSelect(tenant, !!checked) - } - /> - )} - - - {tenant.id} - - -
- - {tenant.name} - - {isSeedTenant(tenant) && ( - - {t( - "ui.admin.tenants.seed_badge", - "초기 설정", - )} - - )} -
-
- - - {tenant.type} - - - - {tenant.slug} - - - - {t( - `ui.common.status.${tenant.status}`, - tenant.status, - )} - - - - {tenant.recursiveMemberCount} - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString( - "ko-KR", - ) - : "-"} - - - - -
- ))} + {shouldVirtualizeTenants + ? virtualTenantRows.map((virtualRow) => { + const tenant = tenants[virtualRow.index]; + if (!tenant) { + return null; + } + return renderTenantRow(tenant, { + virtualIndex: virtualRow.index, + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + display: "table", + tableLayout: "fixed", + transform: `translateY(${virtualRow.start}px)`, + }, + }); + }) + : tenants.map((tenant) => renderTenantRow(tenant))}
diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 951fd8bd..f06afcb4 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -18,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast"; import { approveTenant, deleteTenant, + fetchAllTenants, fetchTenant, - fetchTenants, updateTenant, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; @@ -58,7 +58,7 @@ export function TenantProfilePage() { const parentQuery = useQuery({ queryKey: ["tenants", "list-all"], - queryFn: () => fetchTenants(1000, 0), + queryFn: () => fetchAllTenants(), }); const [name, setName] = useState(""); diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx index dbc965a9..e950bc9e 100644 --- a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx @@ -18,7 +18,7 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; -import { fetchTenants } from "../../../lib/adminApi"; +import { fetchAllTenants } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; function TenantSubTenantsPage() { @@ -27,7 +27,7 @@ function TenantSubTenantsPage() { const { data } = useQuery({ queryKey: ["sub-tenants", tenantId], - queryFn: () => fetchTenants(50, 0, tenantId ?? undefined), + queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }), enabled: !!tenantId, }); diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index 7bfb4891..0551b245 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -21,14 +21,14 @@ import { } from "../../../components/ui/table"; import { type TenantSummary, + fetchAllTenants, fetchGroups, - fetchTenants, } from "../../../lib/adminApi"; export default function GlobalUserGroupListPage() { const { data: tenantList, isLoading: isTenantsLoading } = useQuery({ queryKey: ["admin-tenants"], - queryFn: () => fetchTenants(100, 0), + queryFn: () => fetchAllTenants(), }); if (isTenantsLoading) diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 9fc4b080..c5a682d9 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -74,7 +74,7 @@ import { type UserSummary, createUser, exportTenantsCSV, - fetchTenants, + fetchAllTenants, fetchUsers, updateTenant, updateUser, @@ -449,7 +449,7 @@ function TenantUserGroupsTab() { refetch: refetchTree, } = useQuery({ queryKey: ["tenants-full-tree-v2"], - queryFn: () => fetchTenants(1000, 0), + queryFn: () => fetchAllTenants(), }); const { currentBase, subTree } = useMemo(() => { diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index ceea0c72..e68577aa 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -42,9 +42,9 @@ import { toast } from "../../../components/ui/use-toast"; import { addGroupMember, assignGroupRole, + fetchAllTenants, fetchGroup, fetchGroupRoles, - fetchTenants, fetchUsers, removeGroupMember, removeGroupRole, @@ -91,7 +91,7 @@ export function UserGroupDetailPage() { // Fetch all tenants for role assignment const { data: tenantList } = useQuery({ queryKey: ["admin-tenants"], - queryFn: () => fetchTenants(100, 0), + queryFn: () => fetchAllTenants(), enabled: isAddRoleOpen, }); diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 56d4d8e4..a024ed14 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -42,11 +42,10 @@ import { type UserAppointment, type UserCreateRequest, type UserCreateResponse, - createTenant, createUser, + fetchAllTenants, fetchMe, fetchTenant, - fetchTenants, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { @@ -56,9 +55,10 @@ import { parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; +import { resolvePersonalTenant } from "./utils/personalTenant"; type UserFormValues = UserCreateRequest & { metadata: Record }; -type UserType = "hanmac" | "external" | "personal"; +type UserCategory = "hanmac" | "external" | "personal"; type PickerTarget = { kind: "appointment"; index: number }; @@ -114,8 +114,8 @@ function UserCreatePage() { >(null); const [createdEmail, setCreatedEmail] = React.useState(null); const [autoPassword, setAutoPassword] = React.useState(true); - const [isHanmacFamily, setIsHanmacFamily] = React.useState(true); - const [userType, setUserType] = React.useState("hanmac"); + const [userCategory, setUserCategory] = + React.useState("hanmac"); const [additionalAppointments, setAdditionalAppointments] = React.useState< AppointmentDraft[] >([]); @@ -125,8 +125,8 @@ function UserCreatePage() { const [isResolvingTenant, setIsResolvingTenant] = React.useState(false); const { data: tenantsData } = useQuery({ - queryKey: ["tenants", { limit: 100 }], - queryFn: () => fetchTenants(100, 0), + queryKey: ["tenants", "all"], + queryFn: () => fetchAllTenants(), }); const tenants = tenantsData?.items ?? []; @@ -177,17 +177,11 @@ function UserCreatePage() { const selectedTenantSlug = watch("tenantSlug"); const personalTenant = React.useMemo( - () => - tenants.find( - (tenant) => - tenant.slug === "personal" || - (tenant.type === "PERSONAL" && - tenant.name.toLowerCase() === "personal"), - ), + () => resolvePersonalTenant(tenants), [tenants], ); const selectedTenant = - userType !== "external" + userCategory !== "external" ? undefined : nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug); @@ -231,7 +225,7 @@ function UserCreatePage() { const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, { - tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, + tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined, }, ); @@ -310,25 +304,16 @@ function UserCreatePage() { ); }; - const handleUserTypeChange = (value: string) => { - const nextType = value as UserType; - setUserType(nextType); - setIsHanmacFamily(nextType === "hanmac"); - if (nextType !== "hanmac") { + const handleUserCategoryChange = (value: string) => { + const nextCategory = value as UserCategory; + setUserCategory(nextCategory); + if (nextCategory !== "hanmac") { setAdditionalAppointments([]); } }; const ensurePersonalTenant = async () => { - if (personalTenant) return personalTenant; - const tenant = await createTenant({ - name: "Personal", - slug: "personal", - type: "PERSONAL", - status: "active", - }); - queryClient.invalidateQueries({ queryKey: ["tenants"] }); - return tenant; + return personalTenant; }; const mutation = useMutation({ @@ -355,10 +340,13 @@ function UserCreatePage() { setGeneratedPassword(null); setCreatedEmail(null); + const { + hanmacFamily: _hanmacFamily, + userType: _userType, + ...formMetadata + } = data.metadata ?? {}; const metadata: Record = { - ...(data.metadata ?? {}), - hanmacFamily: userType === "hanmac" && isHanmacFamily, - userType, + ...formMetadata, }; const payload: UserCreateRequest = { @@ -369,7 +357,7 @@ function UserCreatePage() { metadata, }; - if (userType === "external") { + if (userCategory === "external") { if (!data.tenantSlug) { setError( t( @@ -386,7 +374,7 @@ function UserCreatePage() { payload.jobTitle = data.jobTitle; } - if (userType === "personal") { + if (userCategory === "personal") { try { const tenant = await ensurePersonalTenant(); payload.tenantSlug = tenant.slug; @@ -405,7 +393,7 @@ function UserCreatePage() { } } - if (userType === "hanmac") { + if (userCategory === "hanmac") { const appointments = additionalAppointments .filter((appointment) => appointment.tenantId) .map((appointment) => ({ @@ -644,7 +632,7 @@ function UserCreatePage() {
- + & { metadata: Record>; }; -type UserType = "hanmac" | "external" | "personal"; +type UserCategory = "hanmac" | "external" | "personal"; type PasswordResetMode = "generated" | "manual"; type PickerTarget = { kind: "appointment"; index: number }; @@ -318,8 +318,8 @@ function UserDetailPage() { const [passwordResetError, setPasswordResetError] = React.useState< string | null >(null); - const [isHanmacFamily, setIsHanmacFamily] = React.useState(false); - const [userType, setUserType] = React.useState("external"); + const [userCategory, setUserCategory] = + React.useState("external"); const [additionalAppointments, setAdditionalAppointments] = React.useState< AppointmentDraft[] >([]); @@ -346,8 +346,8 @@ function UserDetailPage() { }); const { data: tenantsData } = useQuery({ - queryKey: ["tenants", { limit: 100 }], - queryFn: () => fetchTenants(100, 0), + queryKey: ["tenants", "all"], + queryFn: () => fetchAllTenants(), }); const tenants = React.useMemo( () => tenantsData?.items ?? [], @@ -465,20 +465,14 @@ function UserDetailPage() { return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""; }, [tenants]); const personalTenant = React.useMemo( - () => - tenants.find( - (tenant) => - tenant.slug === "personal" || - (tenant.type === "PERSONAL" && - tenant.name.toLowerCase() === "personal"), - ), + () => resolvePersonalTenant(tenants), [tenants], ); const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, { - tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, + tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined, }, ); @@ -566,25 +560,16 @@ function UserDetailPage() { ); }; - const handleUserTypeChange = (value: string) => { - const nextType = value as UserType; - setUserType(nextType); - setIsHanmacFamily(nextType === "hanmac"); - if (nextType !== "hanmac") { + const handleUserCategoryChange = (value: string) => { + const nextCategory = value as UserCategory; + setUserCategory(nextCategory); + if (nextCategory !== "hanmac") { setAdditionalAppointments([]); } }; const ensurePersonalTenant = async () => { - if (personalTenant) return personalTenant; - const tenant = await createTenant({ - name: "Personal", - slug: "personal", - type: "PERSONAL", - status: "active", - }); - queryClient.invalidateQueries({ queryKey: ["tenants"] }); - return tenant; + return personalTenant; }; React.useEffect(() => { @@ -638,14 +623,18 @@ function UserDetailPage() { tenants, hanmacFamilyTenantId, ); - const resolvedUserType = - metadata.userType === "personal" || user.companyCode === "personal" - ? "personal" - : isUserHanmacFamily - ? "hanmac" - : "external"; - setUserType(resolvedUserType); - setIsHanmacFamily(resolvedUserType === "hanmac"); + const isPersonalUser = + user.companyCode === personalTenant.slug || + user.tenantSlug === personalTenant.slug || + user.tenant?.id === personalTenant.id || + user.tenant?.slug === personalTenant.slug || + metadata.personalTenantId === personalTenant.id; + const resolvedUserCategory = isPersonalUser + ? "personal" + : isUserHanmacFamily + ? "hanmac" + : "external"; + setUserCategory(resolvedUserCategory); const familyFallbackTenants = [ ...(user.joinedTenants ?? []), ...(user.tenant ? [user.tenant] : []), @@ -696,7 +685,7 @@ function UserDetailPage() { : [], ); } - }, [hanmacFamilyTenantId, tenants, user, reset]); + }, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]); const mutation = useMutation({ mutationFn: (data: UserUpdateRequest) => updateUser(userId, data), @@ -737,10 +726,13 @@ function UserDetailPage() { }), ); + const { + hanmacFamily: _hanmacFamily, + userType: _userType, + ...safeMetadata + } = cleanMetadata; const metadata: Record = { - ...cleanMetadata, - hanmacFamily: userType === "hanmac" && isHanmacFamily, - userType, + ...safeMetadata, }; const profileData = { ...data }; @@ -750,7 +742,7 @@ function UserDetailPage() { metadata, }; - if (userType === "personal") { + if (userCategory === "personal") { try { const tenant = await ensurePersonalTenant(); payload.tenantSlug = tenant.slug; @@ -768,7 +760,7 @@ function UserDetailPage() { } } - if (userType === "hanmac") { + if (userCategory === "hanmac") { const appointments = additionalAppointments .filter((appointment) => appointment.tenantId) .map((appointment) => ({ @@ -1071,8 +1063,8 @@ function UserDetailPage() {
@@ -1097,7 +1089,7 @@ function UserDetailPage() { - {userType === "external" && ( + {userCategory === "external" && (