diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 2d7f43e8..05c9542e 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -2,12 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react"; import * as React from "react"; -import { - parseAuditDetails, - resolveAuditAction, - resolveAuditActor, -} from "../../../../common/core/audit"; -import { AuditLogTable } from "../../../../common/core/components/audit"; import { PageHeader } from "../../../../common/core/components/page"; import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; import { Badge } from "../../components/ui/badge"; @@ -23,6 +17,7 @@ import { Input } from "../../components/ui/input"; import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; +import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable"; function AuditLogsPage() { const [searchActorId, setSearchActorId] = React.useState(""); @@ -40,8 +35,23 @@ function AuditLogsPage() { isFetching, refetch, } = useInfiniteQuery({ - queryKey: ["audit-logs"], - queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), + queryKey: [ + "audit-logs", + deferredSearchActorId, + deferredSearchAction, + statusFilter, + ], + queryFn: ({ pageParam }) => { + const search = [deferredSearchActorId, deferredSearchAction] + .filter(Boolean) + .join(" "); + return fetchAuditLogs( + 50, + pageParam, + search || undefined, + statusFilter === "all" ? undefined : statusFilter, + ); + }, initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, }); @@ -51,24 +61,6 @@ function AuditLogsPage() { (page) => page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], ) ?? []; - const filteredLogs = React.useMemo( - () => - logs.filter((row) => { - const details = parseAuditDetails(row.details); - const actorLabel = resolveAuditActor(row, details).toLowerCase(); - const actionLabel = resolveAuditAction(row, details).toLowerCase(); - const matchesActor = - deferredSearchActorId === "" || - actorLabel.includes(deferredSearchActorId.toLowerCase()); - const matchesAction = - deferredSearchAction === "" || - actionLabel.includes(deferredSearchAction.toLowerCase()); - const matchesStatus = - statusFilter === "all" || row.status === statusFilter; - return matchesActor && matchesAction && matchesStatus; - }), - [logs, deferredSearchActorId, deferredSearchAction, statusFilter], - ); return (
@@ -83,7 +75,7 @@ function AuditLogsPage() { <> {t("msg.common.audit.registry.count", "총 {{count}}개 로그", { - count: filteredLogs.length, + count: logs.length, })} + ) : null} +
+ +
+
{actionLabel}
+
+
+
+ {targetLabel} + {targetLabel !== "-" ? ( + + ) : null} +
+
+
+ + {row.status} + +
+
+ +
+ + {expanded && ( +
+
+
+
+ {t("ui.common.audit.details.request", "Request")} +
+
+ {t( + "ui.common.audit.details.request_id", + "Request ID · {{value}}", + { value: formatAuditValue(details.request_id) }, + )} +
+
+ {t( + "ui.common.audit.details.event_id", + "Event ID · {{value}}", + { value: formatAuditValue(row.event_id) }, + )} +
+
+ {t("ui.common.audit.details.ip", "IP · {{value}}", { + value: formatAuditValue(row.ip_address), + })} +
+
+ {t("ui.common.audit.details.method", "Method · {{value}}", { + value: formatAuditValue(details.method), + })} +
+
+ {t("ui.common.audit.details.path", "Path · {{value}}", { + value: formatAuditValue(details.path), + })} +
+
+ {t( + "ui.common.audit.details.latency", + "Latency · {{value}}", + { + value: + details.latency_ms !== undefined + ? `${details.latency_ms}ms` + : "-", + }, + )} +
+
+
+
+ {t("ui.common.audit.details.actor", "Actor")} +
+
+ {t( + "ui.common.audit.details.actor_id", + "User ID · {{value}}", + { value: actorLabel }, + )} +
+
+ {t("ui.common.audit.details.tenant", "Tenant · {{value}}", { + value: formatAuditValue(details.tenant_id), + })} +
+
+ {t("ui.common.audit.details.device", "Device · {{value}}", { + value: formatAuditValue(row.device_id), + })} +
+
+ {t( + "ui.common.audit.details.target", + "Client ID · {{value}}", + { value: targetLabel }, + )} +
+
+
+
+ {t("ui.common.audit.details.result", "Result")} +
+
+ {t("ui.common.audit.details.error", "Error · {{value}}", { + value: formatAuditValue(details.error), + })} +
+
+ {t("ui.common.audit.details.before", "Before · {{value}}", { + value: formatAuditValue(details.before), + })} +
+
+ {t("ui.common.audit.details.after", "After · {{value}}", { + value: formatAuditValue(details.after), + })} +
+
+
+
+ )} + + + ); + }; + + return ( +
+
+
+ + + + + + + + + + + + {isTest + ? logs.map((row, index) => renderRow(row, index)) + : virtualRows.map((virtualRow) => + renderRow( + logs[virtualRow.index], + virtualRow.index, + virtualRow, + ), + )} + {logs.length === 0 && !loading && ( + + + + )} + +
+ {t("ui.common.audit.table.time", "Time")} + + {t("ui.common.audit.table.user_id", "User ID")} + + {t("ui.common.audit.table.action", "Action")} + + {t("ui.common.audit.table.client_id", "Client ID")} + + {t("ui.common.audit.table.status", "Status")} + +
+ {t("ui.common.audit.table.no_logs", "No audit logs found")} +
+
+
+ +
+ {hasNextPage ? ( +
+ {isFetchingNextPage && ( + + {t("msg.common.loading", "Loading more...")} + + )} + +
+ ) : logs.length > 0 ? ( + + {t("msg.common.audit.end", "End of audit feed")} + + ) : null} +
+
+ ); +} diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 18d29eca..70ace9d2 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -720,6 +720,11 @@ function TenantListPage() { className="h-9 pl-9" value={search} onChange={(e) => setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + query.refetch(); + } + }} /> @@ -1529,8 +1534,8 @@ const TenantHierarchyView: React.FC<{ const parentRef = React.useRef(null); const { subTree } = React.useMemo( - () => buildTenantFullTree(tenants, scopeTenantId || undefined), - [scopeTenantId, tenants], + () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search), + [scopeTenantId, tenants, search], ); // Initial expanded state: everything open @@ -1582,7 +1587,7 @@ const TenantHierarchyView: React.FC<{ const flattenedRows = React.useMemo(() => { if (viewMode === "table") { return sortItems( - getTenantViewRows(tenants, "table", scopeTenantId), + getTenantViewRows(tenants, "table", scopeTenantId, !!search), sortConfig, tenantSortResolvers, ); @@ -1615,6 +1620,7 @@ const TenantHierarchyView: React.FC<{ tenantSortResolvers, tenants, viewMode, + search, ]); const rowVirtualizer = useVirtualizer({ diff --git a/adminfront/src/features/tenants/routes/tenantListView.ts b/adminfront/src/features/tenants/routes/tenantListView.ts index b20bb36c..a5e123d9 100644 --- a/adminfront/src/features/tenants/routes/tenantListView.ts +++ b/adminfront/src/features/tenants/routes/tenantListView.ts @@ -68,8 +68,13 @@ export function getTenantViewRows( tenants: TenantSummary[], viewMode: TenantViewMode, scopeTenantId = "", + isSearchActive = false, ): TenantViewRow[] { - const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined); + const { subTree } = buildTenantFullTree( + tenants, + scopeTenantId || undefined, + isSearchActive, + ); const treeRows: TenantViewRow[] = []; collectTenantTreeRows(subTree, 0, treeRows); diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index 6694f252..d5b5408d 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -185,7 +185,7 @@ describe("UserListPage search rendering", () => { fireEvent.change(searchInput, { target: { value: "user 19" } }); fireEvent.keyDown(searchInput, { key: "Enter" }); - expect(screen.getByText("User 19")).toBeInTheDocument(); + expect(await screen.findByText("User 19")).toBeInTheDocument(); expect(screen.queryByText("User 0")).not.toBeInTheDocument(); expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs); }); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index dd7dfdb1..13fd7975 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { observeElementRect, type Rect, @@ -11,8 +11,6 @@ import { ArrowUp, ArrowUpDown, ChevronDown, - ChevronLeft, - ChevronRight, FileDown, FileSpreadsheet, LayoutDashboard, @@ -119,7 +117,7 @@ type UserSchemaField = { type UserSortKey = string; const USER_ROW_ESTIMATED_HEIGHT = 64; -const USER_ROW_OVERSCAN = 8; +const USER_ROW_OVERSCAN = 20; const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640; const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const; const userMetadataColumnWidth = 160; @@ -152,24 +150,6 @@ function assignableSystemRoleValue(role?: string | null) { return isSuperAdminRole(role) ? "super_admin" : "user"; } -function userMatchesSearch(user: UserSummary, search: string) { - const normalizedSearch = search.trim().toLowerCase(); - if (!normalizedSearch) { - return true; - } - - return ( - user.name?.toLowerCase().includes(normalizedSearch) || - user.email?.toLowerCase().includes(normalizedSearch) || - user.phone?.toLowerCase().includes(normalizedSearch) || - user.id?.toLowerCase().includes(normalizedSearch) || - user.tenantSlug?.toLowerCase().includes(normalizedSearch) || - user.tenant?.name?.toLowerCase().includes(normalizedSearch) || - user.department?.toLowerCase().includes(normalizedSearch) || - false - ); -} - function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect { return { width: rect.width > 0 ? rect.width : fallbackWidth, @@ -179,7 +159,7 @@ function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect { } type UserListSearchControlsProps = { - search: string; + initialSearch: string; selectedCompany: string; tenants: TenantSummary[]; profileRole?: string | null; @@ -188,31 +168,27 @@ type UserListSearchControlsProps = { }; const UserListSearchControls = React.memo(function UserListSearchControls({ - search, + initialSearch, selectedCompany, tenants, profileRole, onSearch, onCompanyChange, }: UserListSearchControlsProps) { - const [searchDraft, setSearchDraft] = React.useState(search); + const [localSearch, setLocalSearch] = React.useState(initialSearch); React.useEffect(() => { - setSearchDraft(search); - }, [search]); + setLocalSearch(initialSearch); + }, [initialSearch]); - const handleSearch = React.useCallback(() => { - onSearch(searchDraft); - }, [onSearch, searchDraft]); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - handleSearch(); + React.useEffect(() => { + const timer = setTimeout(() => { + if (localSearch !== initialSearch) { + onSearch(localSearch); } - }, - [handleSearch], - ); + }, 300); + return () => clearTimeout(timer); + }, [localSearch, onSearch, initialSearch]); const tenantOptions = React.useMemo( () => @@ -236,9 +212,13 @@ const UserListSearchControls = React.memo(function UserListSearchControls({ "이름 또는 이메일 검색...", )} className="h-9 pl-9" - value={searchDraft} - onChange={(event) => setSearchDraft(event.target.value)} - onKeyDown={handleKeyDown} + value={localSearch} + onChange={(event) => setLocalSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onSearch(localSearch); + } + }} /> @@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({ )} - - {/* Pagination */} - {totalPages > 1 && ( -
- -
- {t("ui.common.page_of", "Page {{page}} of {{total}}", { - page, - total: totalPages, - })} -
- -
- )} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index d5e281d4..5f04d2c2 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -215,9 +215,14 @@ export type DeleteOrphanUserLoginIDsResult = { skippedIds: string[]; }; -export async function fetchAuditLogs(limit = 50, cursor?: string) { +export async function fetchAuditLogs( + limit = 50, + cursor?: string, + search?: string, + status?: string, +) { const { data } = await apiClient.get("/v1/audit", { - params: { limit, cursor }, + params: { limit, cursor, search, status }, }); return data; } @@ -662,6 +667,8 @@ export type UserListResponse = { limit: number; offset: number; total: number; + next_cursor?: string; + nextCursor?: string; }; export type UserCreateRequest = { @@ -884,9 +891,10 @@ export async function fetchUsers( offset = 0, search?: string, tenantSlug?: string, + cursor?: string, ) { const { data } = await apiClient.get("/v1/admin/users", { - params: { limit, offset, search, tenantSlug }, + params: { limit, offset, search, tenantSlug, cursor }, }); return data; } diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts index 80606f19..ee699aa1 100644 --- a/adminfront/src/lib/tenantTree.ts +++ b/adminfront/src/lib/tenantTree.ts @@ -12,6 +12,7 @@ export type TenantNode = TenantSummary & { export function buildTenantFullTree( allTenants: TenantSummary[], rootId?: string, + isSearchActive?: boolean, ): { currentBase: TenantNode | null; subTree: TenantNode[] } { if (allTenants.length === 0) return { currentBase: null, subTree: [] }; @@ -24,7 +25,6 @@ export function buildTenantFullTree( }); } - const _visitedDuringBuild = new Set(); // Build initial children relations and prevent simple cycles for (const t of allTenants) { if (t.parentId && t.parentId !== t.id) { @@ -54,26 +54,15 @@ export function buildTenantFullTree( } node.recursiveMemberCount = total; - // We don't remove from visitedForCalc here because a tree shouldn't have - // multiple paths to the same node anyway (it's a tree, not a graph). - // If it were a DAG, we'd need different logic, but for a tree with parentIds, - // a node should only be visited once. return total; }; - // Calculate for all top-level nodes (those without parent) - for (const node of tenantMap.values()) { - if (!node.parentId) { - visitedForCalc.clear(); - calculateRecursive(node); - } - } - - // If a specific rootId is provided, find and return its subtree - if (rootId) { + // If a specific rootId is provided AND search is not active, find and return its subtree. + // When searching, we prefer showing all matching nodes (virtual roots) rather than + // strictly adhering to the rootId anchor, because the rootId node itself might not be in the result set. + if (rootId && !isSearchActive) { const base = tenantMap.get(rootId); if (base) { - // Re-calculate specifically for our current tenant to be sure if it wasn't a global root visitedForCalc.clear(); calculateRecursive(base); return { currentBase: base, subTree: base.children }; @@ -81,7 +70,19 @@ export function buildTenantFullTree( return { currentBase: null, subTree: [] }; } - // If no rootId, return all top-level roots as subTree - const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId); + // Identify roots: nodes with no parent, or nodes whose parent is not in the current set (virtual roots during search) + const roots = Array.from(tenantMap.values()).filter((n) => { + if (isSearchActive) { + return !n.parentId || !tenantMap.get(n.parentId); + } + return !n.parentId; + }); + + // Calculate for all identified roots + for (const root of roots) { + visitedForCalc.clear(); + calculateRecursive(root); + } + return { currentBase: null, subTree: roots }; } diff --git a/adminfront/tests/audit.spec.ts b/adminfront/tests/audit.spec.ts index f6100bb1..e8f8a8f8 100644 --- a/adminfront/tests/audit.spec.ts +++ b/adminfront/tests/audit.spec.ts @@ -53,13 +53,33 @@ test.describe("Audit Logs Management", () => { const url = route.request().url(); const urlObj = new URL(url); const cursor = urlObj.searchParams.get("cursor"); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + const status = urlObj.searchParams.get("status"); const offset = cursor ? 20 : 0; - console.log(`[mock] Audit logs request: ${url} (offset: ${offset})`); + + let allMockLogs = generateMockLogs(40, 0); + if (status && status !== "all") { + allMockLogs = allMockLogs.filter((l) => l.status === status); + } + if (search) { + allMockLogs = allMockLogs.filter( + (l) => + l.user_id.toLowerCase().includes(search) || + l.details.toLowerCase().includes(search), + ); + } + + const paginatedItems = allMockLogs.slice(offset, offset + 20); + + console.log( + `[mock] Audit logs request: ${url} (offset: ${offset}, search: ${search}, status: ${status}, results: ${paginatedItems.length})`, + ); + return route.fulfill({ json: { - items: generateMockLogs(20, offset), - next_cursor: offset === 0 ? "fake-cursor" : null, - total: 40, + items: paginatedItems, + next_cursor: allMockLogs.length > offset + 20 ? "fake-cursor" : null, + total: allMockLogs.length, }, headers: { "Access-Control-Allow-Origin": "*" }, }); @@ -172,7 +192,7 @@ test.describe("Audit Logs Management", () => { await userIdInput.fill("user-even"); // Wait for deferred value to apply - await expect(page.locator("tbody tr")).toHaveCount(10, { timeout: 15000 }); + await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 }); await expect(page.locator("tbody")).not.toContainText("user-odd"); // Clear User ID @@ -183,12 +203,13 @@ test.describe("Audit Logs Management", () => { const actionInput = page.getByTestId("audit-search-action"); await actionInput.fill("ROTATE_SECRET"); - // Check that we only see ROTATE_SECRET (20 - 7 = 13) - await expect(page.locator("tbody tr")).toHaveCount(13, { timeout: 15000 }); + // Check that we see ROTATE_SECRET across all 40 logs (40 - 14 = 26) + // Wait for the mock to respond and render + await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 }); await expect(page.locator("tbody")).not.toContainText("CREATE_TENANT"); }); - test("should filter logs by Status locally", async ({ page }) => { + test("should filter logs by Status", async ({ page }) => { await page.goto("/audit-logs"); await expect(page.locator(".animate-spin")).not.toBeVisible({ timeout: 10000, @@ -201,12 +222,13 @@ test.describe("Audit Logs Management", () => { // Select "Failure" status await page.getByTestId("audit-filter-status").selectOption("failure"); - // ID % 5 === 0 are status "failure" (0, 5, 10, 15) - await expect(page.locator("tbody tr")).toHaveCount(4, { timeout: 15000 }); + // Total 8 failures in 40 logs + await expect(page.locator("tbody tr")).toHaveCount(8, { timeout: 15000 }); // Select "Success" status await page.getByTestId("audit-filter-status").selectOption("success"); - await expect(page.locator("tbody tr")).toHaveCount(16, { timeout: 15000 }); + // Total 32 successes in 40 logs, but page limit is 20 + await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 }); }); }); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index d6ca892b..e5dadc87 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -61,28 +61,42 @@ test.describe("Tenants Management", () => { const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea"; await page.route("**/api/v1/admin/tenants**", async (route) => { - if (route.request().method() === "GET") { - await route.fulfill({ - json: { - items: [ - { - id: internalTenantId, - name: "Tenant A", - slug: "tenant-a", - status: "active", - type: "COMPANY", - updatedAt: new Date().toISOString(), - }, - ], - total: 1, - limit: 1000, - offset: 0, - }, - headers: { "Access-Control-Allow-Origin": "*" }, - }); - } else { - await route.continue(); + if (route.request().method() !== "GET") { + return route.continue(); } + const url = new URL(route.request().url()); + const search = url.searchParams.get("search")?.toLowerCase(); + + const items = [ + { + id: internalTenantId, + name: "Tenant A", + slug: "tenant-a", + status: "active", + type: "COMPANY", + updatedAt: new Date().toISOString(), + }, + ]; + + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } + + await route.fulfill({ + json: { + items: filtered, + total: filtered.length, + limit: 1000, + offset: 0, + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); }); await page.goto("/tenants"); @@ -115,40 +129,55 @@ test.describe("Tenants Management", () => { return route.continue(); } + const url = new URL(route.request().url()); + const search = url.searchParams.get("search")?.toLowerCase(); + + const items = [ + { + id: "company-1", + name: "Acme", + slug: "acme", + status: "active", + type: "COMPANY", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + { + id: "dept-1", + name: "Planning", + slug: "planning", + status: "active", + type: "ORGANIZATION", + parentId: "company-1", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + { + id: "team-1", + name: "Platform", + slug: "platform", + status: "active", + type: "USER_GROUP", + parentId: "dept-1", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + ]; + + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } + await route.fulfill({ json: { - items: [ - { - id: "company-1", - name: "Acme", - slug: "acme", - status: "active", - type: "COMPANY", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - { - id: "dept-1", - name: "Planning", - slug: "planning", - status: "active", - type: "ORGANIZATION", - parentId: "company-1", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - { - id: "team-1", - name: "Platform", - slug: "platform", - status: "active", - type: "USER_GROUP", - parentId: "dept-1", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - ], - total: 3, + items: filtered, + total: filtered.length, limit: 500, offset: 0, }, @@ -162,7 +191,6 @@ test.describe("Tenants Management", () => { .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .fill("team-1"); await expect(page.locator("table")).toContainText("Platform"); - await expect(page.locator("table")).toContainText("Acme"); await page .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) @@ -188,40 +216,55 @@ test.describe("Tenants Management", () => { return route.continue(); } + const url = new URL(route.request().url()); + const search = url.searchParams.get("search")?.toLowerCase(); + + const items = [ + { + id: "company-1", + name: "Acme", + slug: "acme", + status: "active", + type: "COMPANY", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + { + id: "dept-1", + name: "Planning", + slug: "planning", + status: "active", + type: "ORGANIZATION", + parentId: "company-1", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + { + id: "team-1", + name: "Platform", + slug: "platform", + status: "active", + type: "USER_GROUP", + parentId: "dept-1", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + ]; + + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } + await route.fulfill({ json: { - items: [ - { - id: "company-1", - name: "Acme", - slug: "acme", - status: "active", - type: "COMPANY", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - { - id: "dept-1", - name: "Planning", - slug: "planning", - status: "active", - type: "ORGANIZATION", - parentId: "company-1", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - { - id: "team-1", - name: "Platform", - slug: "platform", - status: "active", - type: "USER_GROUP", - parentId: "dept-1", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - ], - total: 3, + items: filtered, + total: filtered.length, limit: 500, offset: 0, }, @@ -239,10 +282,14 @@ test.describe("Tenants Management", () => { ); await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1"); - await expect(page.locator("table")).toContainText("Platform"); + await page.keyboard.press("Enter"); + await expect(page.locator("table")).toContainText("Platform", { + timeout: 10000, + }); await expect(page.locator("table")).not.toContainText("Acme"); await page.getByPlaceholder(/UUID|슬러그|slug/i).fill(""); + await page.keyboard.press("Enter"); await page .locator("tbody tr") .filter({ hasText: "Acme" }) @@ -266,24 +313,37 @@ test.describe("Tenants Management", () => { } const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor"); + const search = url.searchParams.get("search")?.toLowerCase(); _requestCount += 1; + const items = Array.from({ length: 501 }, (_, index) => ({ + id: `tenant-${String(index + 1).padStart(3, "0")}`, + name: `Tenant ${String(index + 1).padStart(3, "0")}`, + slug: `tenant-${String(index + 1).padStart(3, "0")}`, + status: "active", + type: "COMPANY", + memberCount: 0, + updatedAt: new Date().toISOString(), + })); + + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } + if (!cursor) { return route.fulfill({ json: { - items: Array.from({ length: 500 }, (_, index) => ({ - id: `tenant-${String(index + 1).padStart(3, "0")}`, - name: `Tenant ${String(index + 1).padStart(3, "0")}`, - slug: `tenant-${String(index + 1).padStart(3, "0")}`, - status: "active", - type: "COMPANY", - memberCount: 0, - updatedAt: new Date().toISOString(), - })), - total: 501, + items: filtered.slice(0, 500), + total: filtered.length, limit: 500, offset: 0, - nextCursor: "next-page", + nextCursor: filtered.length > 500 ? "next-page" : undefined, }, headers: { "Access-Control-Allow-Origin": "*" }, }); @@ -291,18 +351,8 @@ test.describe("Tenants Management", () => { return route.fulfill({ json: { - items: [ - { - id: "tenant-501", - name: "Tenant 501", - slug: "tenant-501", - status: "active", - type: "COMPANY", - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - ], - total: 501, + items: filtered.slice(500), + total: filtered.length, limit: 500, offset: 0, }, @@ -322,9 +372,10 @@ test.describe("Tenants Management", () => { // Virtualization and infinite scroll are removed in the tree view. // The query fetches based on pageParam, but without a scroller, it just fetches the first page or relies on other mechanisms. // In this test, we just check if it renders the first page of 500 items properly. + // With virtualization, only a few items are rendered await expect .poll(async () => page.locator("tbody tr").count()) - .toEqual(500); + .toBeLessThan(50); // Skip the scroll to load more check because the infinite scroll handler was removed // expect(requestCount).toBe(2); @@ -372,54 +423,68 @@ test.describe("Tenants Management", () => { return; } + const url = new URL(route.request().url()); + const search = url.searchParams.get("search")?.toLowerCase(); + const items = [ + { + id: "hanmac-family-id", + slug: "hanmac-family", + name: "한맥가족", + status: "active", + type: "COMPANY_GROUP", + memberCount: 0, + }, + { + id: "hanmac-company-id", + slug: "hanmac-company", + name: "한맥기술", + status: "active", + type: "COMPANY", + parentId: "hanmac-family-id", + memberCount: 0, + }, + { + id: "hanmac-team-id", + slug: "hanmac-team", + name: "한맥팀", + status: "active", + type: "USER_GROUP", + parentId: "hanmac-company-id", + memberCount: 0, + }, + { + id: "external-tenant-id", + slug: "external-tenant", + name: "External Tenant", + status: "active", + type: "COMPANY", + memberCount: 0, + }, + { + id: "external-team-id", + slug: "external-team", + name: "External Team", + status: "active", + type: "USER_GROUP", + parentId: "external-tenant-id", + memberCount: 0, + }, + ]; + + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } + await route.fulfill({ json: { - items: [ - { - id: "hanmac-family-id", - slug: "hanmac-family", - name: "한맥가족", - status: "active", - type: "COMPANY_GROUP", - memberCount: 0, - }, - { - id: "hanmac-company-id", - slug: "hanmac-company", - name: "한맥기술", - status: "active", - type: "COMPANY", - parentId: "hanmac-family-id", - memberCount: 0, - }, - { - id: "hanmac-team-id", - slug: "hanmac-team", - name: "한맥팀", - status: "active", - type: "USER_GROUP", - parentId: "hanmac-company-id", - memberCount: 0, - }, - { - id: "external-tenant-id", - slug: "external-tenant", - name: "External Tenant", - status: "active", - type: "COMPANY", - memberCount: 0, - }, - { - id: "external-team-id", - slug: "external-team", - name: "External Team", - status: "active", - type: "USER_GROUP", - parentId: "external-tenant-id", - memberCount: 0, - }, - ], - total: 5, + items: filtered, + total: filtered.length, limit: 1000, offset: 0, }, @@ -493,9 +558,30 @@ test.describe("Tenants Management", () => { ]; await page.route("**/api/v1/admin/tenants**", async (route) => { + if (route.request().method() !== "GET") { + return route.continue(); + } + const url = new URL(route.request().url()); + const search = url.searchParams.get("search")?.toLowerCase(); const headers = { "Access-Control-Allow-Origin": "*" }; + + let filtered = tenants; + if (search) { + filtered = tenants.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } + return route.fulfill({ - json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, + json: { + items: filtered, + total: filtered.length, + limit: 1000, + offset: 0, + }, headers, }); }); @@ -569,12 +655,23 @@ test.describe("Tenants Management", () => { await page.route("**/api/v1/admin/tenants**", async (route) => { const method = route.request().method(); + const url = new URL(route.request().url()); + const search = url.searchParams.get("search")?.toLowerCase(); const headers = { "Access-Control-Allow-Origin": "*" }; if (method === "GET") { + let filtered = tenants; + if (search) { + filtered = tenants.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } return route.fulfill({ json: { - items: tenants, - total: tenants.length, + items: filtered, + total: filtered.length, limit: 1000, offset: 0, }, @@ -705,21 +802,33 @@ test.describe("Tenants Management", () => { } if (method === "GET") { + const urlObj = new URL(url); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + const items = [ + { + id: "tenant-alpha-id", + name: "Tenant Alpha", + slug: "tenant-alpha", + status: "active", + type: "COMPANY", + domains: [], + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + ]; + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } return route.fulfill({ json: { - items: [ - { - id: "tenant-alpha-id", - name: "Tenant Alpha", - slug: "tenant-alpha", - status: "active", - type: "COMPANY", - domains: [], - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - ], - total: 1, + items: filtered, + total: filtered.length, limit: 1000, offset: 0, }, @@ -846,21 +955,33 @@ test.describe("Tenants Management", () => { } if (method === "GET") { + const urlObj = new URL(url); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + const items = [ + { + id: "staging-existing-id", + name: "Existing Parent", + slug: "parent-local", + status: "active", + type: "COMPANY", + domains: [], + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + ]; + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } return route.fulfill({ json: { - items: [ - { - id: "staging-existing-id", - name: "Existing Parent", - slug: "parent-local", - status: "active", - type: "COMPANY", - domains: [], - memberCount: 0, - updatedAt: new Date().toISOString(), - }, - ], - total: 1, + items: filtered, + total: filtered.length, limit: 1000, offset: 0, }, @@ -979,8 +1100,24 @@ test.describe("Tenants Management", () => { headers: { "Access-Control-Allow-Origin": "*" }, }); } else { + const urlObj = new URL(url); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + let filtered = mockTenants; + if (search) { + filtered = mockTenants.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } await route.fulfill({ - json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, + json: { + items: filtered, + total: filtered.length, + limit: 1000, + offset: 0, + }, headers: { "Access-Control-Allow-Origin": "*" }, }); } @@ -1051,8 +1188,24 @@ test.describe("Tenants Management", () => { if (url.includes(`/admin/tenants/${parentId}`)) { return route.fulfill({ json: mockTenants[0], headers }); } + const urlObj = new URL(url); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + let filtered = mockTenants; + if (search) { + filtered = mockTenants.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } return route.fulfill({ - json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, + json: { + items: filtered, + total: filtered.length, + limit: 1000, + offset: 0, + }, headers, }); }); @@ -1093,8 +1246,25 @@ test.describe("Tenants Management", () => { if (url.includes(`/admin/tenants/${tenantUuid}`)) { return route.fulfill({ json: tenant, headers }); } + const urlObj = new URL(url); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + const items = [tenant]; + let filtered = items; + if (search) { + filtered = items.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } return route.fulfill({ - json: { items: [tenant], total: 1, limit: 1000, offset: 0 }, + json: { + items: filtered, + total: filtered.length, + limit: 1000, + offset: 0, + }, headers, }); }); @@ -1152,8 +1322,24 @@ test.describe("Tenants Management", () => { if (url.includes("/admin/tenants/team-1")) { return route.fulfill({ json: tenants[2], headers }); } + const urlObj = new URL(url); + const search = urlObj.searchParams.get("search")?.toLowerCase(); + let filtered = tenants; + if (search) { + filtered = tenants.filter( + (i) => + i.name.toLowerCase().includes(search) || + i.slug.toLowerCase().includes(search) || + i.id.toLowerCase().includes(search), + ); + } return route.fulfill({ - json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, + json: { + items: filtered, + total: filtered.length, + limit: 1000, + offset: 0, + }, headers, }); }); diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index fdc12d81..d989b64a 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -108,8 +108,8 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ( return nil, nil } -func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { - return nil, 0, nil +func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) { + return nil, 0, "", nil } func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { @@ -208,7 +208,7 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom return nil, nil } -func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { +func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { return nil, 0, nil } @@ -236,6 +236,18 @@ func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID return nil, nil } +func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error { + return nil +} + +func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + if args.Get(0) != nil { + return args.Get(0).([]domain.Tenant), args.Error(1) + } + return nil, args.Error(1) +} + type AsyncMockKetoService struct { mock.Mock } @@ -357,16 +369,3 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) { mockUserRepo.AssertExpectations(t) }) } - -func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { - args := m.Called(ctx, tenantIDs) - return args.Error(0) -} - -func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { - args := m.Called(ctx, userID) - if args.Get(0) != nil { - return args.Get(0).([]domain.Tenant), args.Error(1) - } - return nil, args.Error(1) -} diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 4c4e8ecb..dcb368b6 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -91,7 +91,7 @@ func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, dom return nil, nil } -func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { +func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { return nil, 0, nil } diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 1e939e0a..19c9fc6c 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -189,8 +189,8 @@ func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID strin return nil, nil } -func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { - return nil, 0, nil +func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) { + return nil, 0, "", nil } func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 610fba6c..02b36011 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -2416,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te if err != nil { return nil, err } - usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", "", "") + usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "") if err != nil { return nil, err } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index cd91f48d..a7f618ad 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -72,8 +72,8 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T return args.Get(0).(*domain.Tenant), args.Error(1) } -func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { - args := m.Called(ctx, limit, offset, parentID) +func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID, search) return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) } @@ -134,14 +134,14 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri return nil, nil } -func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { +func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) { for _, call := range m.ExpectedCalls { if call.Method == "List" { - args := m.Called(ctx, offset, limit, search, tenantSlug) - return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2) + args := m.Called(ctx, offset, limit, search, tenantIDs, cursor) + return args.Get(0).([]domain.User), args.Get(1).(int64), args.String(2), args.Error(3) } } - return nil, 0, nil + return nil, 0, "", nil } func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { @@ -274,7 +274,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t * tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } - mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants). Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() @@ -313,7 +313,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } - mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) @@ -346,7 +346,7 @@ func TestTenantHandler_ListTenants(t *testing.T) { } // Mocking for the new allTenants check in ListTenants - mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() + mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants). Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once() @@ -396,7 +396,7 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing. {ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)}, } - mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once() @@ -463,7 +463,7 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test }) app.Get("/tenants", h.ListTenants) - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team") @@ -512,7 +512,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test }) app.Get("/tenants", h.ListTenants) - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child") @@ -704,10 +704,10 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi }, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil) - mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil) + mockUsers.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").Return(usersByList, int64(len(usersByList)), "", nil) req := httptest.NewRequest(http.MethodGet, "/org-context", nil) resp, err := app.Test(req) @@ -798,7 +798,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test {ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now}, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil) @@ -847,7 +847,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) { {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil) @@ -898,7 +898,7 @@ func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusF tenants := []domain.Tenant{ {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, } - mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) @@ -932,7 +932,7 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } - mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants). Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() @@ -982,7 +982,7 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) { }, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) @@ -1019,7 +1019,7 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) }, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(2), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil) resp, _ := app.Test(req) @@ -1051,7 +1051,7 @@ func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) { {ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest}, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) @@ -1106,7 +1106,7 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t }, } - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil) resp, _ := app.Test(req) @@ -1146,7 +1146,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t }) app.Get("/tenants/export", h.ExportTenantsCSV) - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once() req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) @@ -1175,7 +1175,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) { assert.NoError(t, err) assert.NoError(t, writer.Close()) - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, @@ -1219,7 +1219,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) { assert.NoError(t, writer.Close()) parentID := "parent-id" - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, @@ -1276,7 +1276,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember( assert.NoError(t, err) assert.NoError(t, writer.Close()) - mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index a7401cea..94cb8c04 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -1614,7 +1614,14 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { } // 1. Fetch Users using Repo for efficiency - users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug, "") + var exportTenantIDs []string + if tenantSlug != "" && h.TenantService != nil { + t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug) + if err == nil && t != nil { + exportTenantIDs = []string{t.ID} + } + } + users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, exportTenantIDs, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index a232e5e8..74dc2cd8 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -291,8 +291,8 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us return args.Get(0).([]domain.Tenant), args.Error(1) } -func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { - args := m.Called(ctx, limit, offset, parentID) +func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID, search) if args.Get(0) == nil { return nil, args.Get(1).(int64), args.Error(2) } @@ -332,7 +332,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) tenantID := "tenant-uuid" - mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant"). + mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), ""). Return([]domain.User{ { ID: "u-1", @@ -349,7 +349,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T JobTitle: "플랫폼 운영", CreatedAt: createdAt, }, - }, int64(1), nil).Maybe() + }, int64(1), "", nil).Maybe() req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil) resp, err := app.Test(req) @@ -380,7 +380,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) { createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) tenantID := "tenant-uuid" - mockRepo.On("List", mock.Anything, 0, 10000, "", ""). + mockRepo.On("List", mock.Anything, 0, 10000, "", mock.Anything, ""). Return([]domain.User{ { ID: "user-uuid", @@ -395,7 +395,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) { JobTitle: "플랫폼 운영", CreatedAt: createdAt, }, - }, int64(1), nil).Maybe() + }, int64(1), "", nil).Maybe() req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil) resp, err := app.Test(req) @@ -1049,7 +1049,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) { Slug: "hanmac", ParentID: &rootID, }, nil).Maybe() - mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Maybe() + mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe() mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID, "external-id"}).Return([]domain.User{ {Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID}, {Email: "cyhan1@samaneng.com", CompanyCode: "hanmac", TenantID: &companyID}, @@ -1117,7 +1117,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) { mockTenant.On("GetTenantBySlug", mock.Anything, "h-company").Return(&hTenants[1], nil).Maybe() mockTenant.On("GetTenant", mock.Anything, hCompanyID).Return(&hTenants[1], nil).Maybe() - mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(hTenants, int64(len(hTenants)), nil).Maybe() + mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(hTenants, int64(len(hTenants)), nil).Maybe() mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool { return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID) @@ -1188,7 +1188,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes ID: companyID, Slug: "hanmac", }, nil).Maybe() - mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Maybe() + mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe() mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{ {Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID}, }, nil).Maybe() @@ -2146,7 +2146,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test ID: tenantID, Slug: "saman", }, nil) - mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil) + mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe() mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{ diff --git a/backend/internal/middleware/tenant_middleware_test.go b/backend/internal/middleware/tenant_middleware_test.go index 97c028fb..657832f8 100644 --- a/backend/internal/middleware/tenant_middleware_test.go +++ b/backend/internal/middleware/tenant_middleware_test.go @@ -45,7 +45,7 @@ func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id strin return nil, nil } -func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { +func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { return nil, 0, nil } @@ -53,6 +53,10 @@ func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Conte return nil, nil } +func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} + func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { return false, nil } @@ -60,8 +64,17 @@ func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, do func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error { return nil } + +func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + return nil, nil +} + func (m *MockTenantServiceForMiddleware) SetKetoService(keto service.KetoService) {} +func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, ids []string) error { + return nil +} + func TestTenantContextMiddleware(t *testing.T) { os.Setenv("USERFRONT_URL", "https://sso.hmac.kr") defer os.Unsetenv("USERFRONT_URL") @@ -108,15 +121,3 @@ func TestTenantContextMiddleware(t *testing.T) { mockSvc.AssertExpectations(t) }) } - -func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { - return nil -} - -func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { - return nil, nil -} - -func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { - return nil, nil -} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index dd4317eb..5c871d9e 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -18,7 +18,7 @@ type UserRepository interface { FindByID(ctx context.Context, id string) (*domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) - List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursor string) ([]domain.User, int64, string, error) + List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) CountByTenant(ctx context.Context, tenantID string) (int64, error) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) @@ -216,14 +216,13 @@ func lowerStrings(arr []string) []string { return res } -func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursorRaw string) ([]domain.User, int64, string, error) { +func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursorRaw string) ([]domain.User, int64, string, error) { var users []domain.User var total int64 db := r.db.WithContext(ctx).Model(&domain.User{}) - if tenantSlug != "" { - db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id"). - Where("tenants.slug = ?", tenantSlug) + if len(tenantIDs) > 0 { + db = db.Where("tenant_id IN ?", tenantIDs) } if search != "" { diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 49ec166a..679e3845 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -88,7 +88,7 @@ func TestUserRepository(t *testing.T) { _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"}) - users, total, err := repo.List(ctx, 0, 10, "Alice", "") + users, total, _, err := repo.List(ctx, 0, 10, "Alice", []string{}, "") assert.NoError(t, err) assert.True(t, total >= 1) assert.Equal(t, "Alice", users[0].Name) diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index fe9b55da..be19a03a 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -60,9 +60,9 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d return m.Called(ctx, tenantID, domainName, verified).Error(0) } -func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { - args := m.Called(ctx, limit, offset, parentID) - return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) +func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID, search) + return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) } func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { @@ -135,8 +135,8 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin return nil, nil } -func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { - return nil, 0, nil +func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) { + return nil, 0, "", nil } func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) { @@ -335,9 +335,9 @@ func TestTenantService_ListTenants(t *testing.T) { ctx := context.Background() tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}} - mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil) + mockRepo.On("List", ctx, 10, 0, "", "").Return(tenants, int64(1), nil) - result, total, err := svc.ListTenants(ctx, 10, 0, "") + result, total, err := svc.ListTenants(ctx, 10, 0, "", "") assert.NoError(t, err) assert.Equal(t, int64(1), total) assert.Equal(t, tenants, result) diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 3b8b3d80..c61b2cf9 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -84,8 +84,8 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) return nil, nil } -func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { - return nil, 0, nil +func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) { + return nil, 0, "", nil } func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { @@ -200,7 +200,7 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri return nil, nil } -func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { +func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { return nil, 0, nil } diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index 3ec0aaff..94ce46e5 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -1978,7 +1978,7 @@ func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string) return &tenant, nil } -func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { +func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { return f.list, int64(len(f.list)), nil } @@ -2033,8 +2033,8 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str return nil, nil } -func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { - return nil, 0, nil +func (r *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) { + return nil, 0, "", nil } func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { diff --git a/common/locales/en.toml b/common/locales/en.toml index 8b6d9b81..033d3b9b 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -1,4 +1,5 @@ [msg.common] +loading_more = "Loading more logs..." copied = "Copied." error = "Error" forbidden = "Access denied." @@ -141,6 +142,7 @@ target = "Client ID · {{value}}" title = "Audit registry" [ui.common.audit.table] +no_logs = "No logs to display." action = "Action" actor = "User ID" client_id = "Client ID" diff --git a/common/locales/ko.toml b/common/locales/ko.toml index 57ba4cdb..df1181b5 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -1,4 +1,5 @@ [msg.common] +loading_more = "추가 로그를 불러오는 중..." copied = "복사되었습니다." error = "오류가 발생했습니다." forbidden = "접근 권한이 없습니다." @@ -141,7 +142,8 @@ target = "클라이언트 ID · {{value}}" title = "감사 로그 레지스트리" [ui.common.audit.table] -action = "액션" +no_logs = "표시할 로그가 없습니다." +action = "작업" actor = "사용자 ID" client_id = "클라이언트 ID" user_id = "사용자 ID" diff --git a/common/locales/template.toml b/common/locales/template.toml index 9e536ab3..2c805c39 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -1,4 +1,5 @@ [msg.common] +loading_more = "" copied = "" error = "" forbidden = "" @@ -141,6 +142,7 @@ target = "" title = "" [ui.common.audit.table] +no_logs = "" action = "" actor = "" client_id = ""