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,
})}
@@ -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({
onSearch(localSearch)}
className="h-9"
>
{t("ui.common.search", "검색")}
@@ -268,7 +248,6 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
function UserListPage() {
const _navigate = useNavigate();
- const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState("");
const [visibleColumns, setVisibleColumns] = React.useState<
@@ -285,9 +264,6 @@ function UserListPage() {
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const userTableViewportRef = React.useRef(null);
- const limit = 1000;
- const offset = (page - 1) * limit;
-
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
@@ -345,10 +321,12 @@ function UserListPage() {
}));
};
- const query = useQuery({
- queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
- queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
- placeholderData: (previousData) => previousData,
+ const query = useInfiniteQuery({
+ queryKey: ["users", { search, tenantSlug: selectedCompany }],
+ queryFn: ({ pageParam }) =>
+ fetchUsers(50, 0, search, selectedCompany, pageParam as string),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
});
const deleteMutation = useMutation({
@@ -393,12 +371,10 @@ function UserListPage() {
const handleSearch = React.useCallback((nextSearch: string) => {
setSearch(nextSearch);
- setPage(1);
}, []);
const handleCompanyChange = React.useCallback((nextCompany: string) => {
setSelectedCompany(nextCompany);
- setPage(1);
}, []);
const handleExport = (includeIds = false) => {
@@ -415,14 +391,11 @@ function UserListPage() {
)
: null;
- const serverItems = query.data?.items ?? [];
- const rawItems = React.useMemo(() => {
- if (!query.isFetching || search.trim() === "") {
- return serverItems;
- }
-
- return serverItems.filter((user) => userMatchesSearch(user, search));
- }, [query.isFetching, search, serverItems]);
+ const serverItems = React.useMemo(
+ () => query.data?.pages.flatMap((page) => page.items) ?? [],
+ [query.data],
+ );
+ const rawItems = serverItems;
const userSortResolvers = React.useMemo<
SortResolverMap
>(
@@ -496,6 +469,25 @@ function UserListPage() {
},
});
const virtualRows = rowVirtualizer.getVirtualItems();
+
+ const lastItem = virtualRows[virtualRows.length - 1];
+ React.useEffect(() => {
+ if (!lastItem) return;
+ if (
+ lastItem.index >= serverItems.length - 1 &&
+ query.hasNextPage &&
+ !query.isFetchingNextPage
+ ) {
+ query.fetchNextPage();
+ }
+ }, [
+ lastItem,
+ serverItems.length,
+ query.hasNextPage,
+ query.isFetchingNextPage,
+ query.fetchNextPage,
+ ]);
+
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length;
@@ -514,8 +506,7 @@ function UserListPage() {
);
};
- const total = query.data?.total ?? 0;
- const totalPages = Math.ceil(total / limit);
+ const total = query.data?.pages[0]?.total ?? 0;
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => {
@@ -627,10 +618,10 @@ function UserListPage() {
actions={
<>
@@ -1241,36 +1232,6 @@ function UserListPage() {
)}
-
- {/* Pagination */}
- {totalPages > 1 && (
-
-
setPage((p) => Math.max(1, p - 1))}
- disabled={page === 1 || query.isFetching}
- >
-
- {t("ui.common.previous", "Previous")}
-
-
- {t("ui.common.page_of", "Page {{page}} of {{total}}", {
- page,
- total: totalPages,
- })}
-
-
setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page === totalPages || query.isFetching}
- >
- {t("ui.common.next", "Next")}
-
-
-
- )}
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 = ""