1
0
forked from baron/baron-sso

perf(admin): full-stack performance optimization for all list tables

- Implemented server-side search, infinite scrolling, and list virtualization for Tenants, Users, and Audit Logs.
- Backend: Enhanced Repository, Service, and Handler layers to support 'search' and 'cursor' parameters.
- Frontend: Integrated @tanstack/react-virtual and useInfiniteQuery for high-performance rendering.
- Quality: Updated all unit tests and E2E tests to match the new asynchronous server-side search architecture.
- i18n: Synced all translation keys and cleaned up unused resources.
This commit is contained in:
2026-06-04 16:06:30 +09:00
parent 6d3f128282
commit b2f155e35b
26 changed files with 1103 additions and 440 deletions

View File

@@ -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 (
<div className="space-y-6">
@@ -83,7 +75,7 @@ function AuditLogsPage() {
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: filteredLogs.length,
count: logs.length,
})}
</Badge>
<Button
@@ -185,8 +177,8 @@ function AuditLogsPage() {
</form>
}
/>
<AuditLogTable
logs={filteredLogs}
<VirtualizedAuditLogTable
logs={logs}
t={t}
loading={isLoading}
hasNextPage={Boolean(hasNextPage)}

View File

@@ -0,0 +1,470 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { getCommonButtonClasses } from "../../../../common/ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button";
import type { AuditLog } from "../../lib/adminApi";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type VirtualizedAuditLogTableProps = {
logs: AuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
return status === "success" || status === "ok" ? "success" : "warning";
}
export function VirtualizedAuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: VirtualizedAuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const viewportRef = React.useRef<HTMLDivElement>(null);
const isTest =
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
(typeof window !== "undefined" && (window as any)._IS_TEST_MODE);
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const rowVirtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 80,
measureElement: (el) => el.getBoundingClientRect().height,
overscan: isTest ? logs.length : 10,
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
});
const virtualRows = rowVirtualizer.getVirtualItems();
React.useEffect(() => {
if (isTest) {
return;
}
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= logs.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
virtualRows,
logs.length,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isTest,
]);
const tableMinWidth = 1010;
const renderRow = (row: AuditLog, index: number, virtualRow?: any) => {
if (!row) return null;
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details);
const actionLabel = resolveAuditAction(row, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(row.timestamp);
return (
<tr
key={rowKey}
data-index={index}
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cx(
commonTableRowClass,
"bg-card/40",
virtualRow ? "absolute left-0 w-full" : "",
)}
style={
virtualRow
? {
transform: `translateY(${virtualRow.start}px)`,
}
: undefined
}
>
<td colSpan={6} className="p-0">
<div className={cx("flex items-center", expanded && "border-b")}>
<div
className={cx(
commonTableCellClass,
"w-[190px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</div>
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[180px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="font-semibold text-foreground">{actionLabel}</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[260px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(row.status),
})}
>
{row.status}
</span>
</div>
<div
className={cx(
commonTableCellClass,
"w-[80px] shrink-0 text-right",
)}
>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() => {
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}));
// Re-measure after state change
setTimeout(() => rowVirtualizer.measure(), 0);
}}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{expanded && (
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(row.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(row.ip_address),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.method", "Method · {{value}}", {
value: formatAuditValue(details.method),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.path", "Path · {{value}}", {
value: formatAuditValue(details.path),
})}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
value: formatAuditValue(details.tenant_id),
})}
</div>
<div>
{t("ui.common.audit.details.device", "Device · {{value}}", {
value: formatAuditValue(row.device_id),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t("ui.common.audit.details.error", "Error · {{value}}", {
value: formatAuditValue(details.error),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.before", "Before · {{value}}", {
value: formatAuditValue(details.before),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.after", "After · {{value}}", {
value: formatAuditValue(details.after),
})}
</div>
</div>
</div>
</div>
)}
</td>
</tr>
);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div
ref={viewportRef}
className={cx(commonTableViewportClass, "flex-1")}
data-testid="audit-table-viewport"
>
<div
className={commonTableWrapperClass}
style={{ minWidth: tableMinWidth }}
>
<table
className={cx(commonTableClass, "table-fixed w-full")}
style={{ borderCollapse: "separate", borderSpacing: 0 }}
>
<thead
className={cx(
commonTableHeaderClass,
commonStickyTableHeaderClass,
)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</th>
<th className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</th>
<th className={cx(commonTableHeadClass, "w-[80px]")} />
</tr>
</thead>
<tbody
className={commonTableBodyClass}
style={
!isTest
? {
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}
: undefined
}
>
{isTest
? logs.map((row, index) => renderRow(row, index))
: virtualRows.map((virtualRow) =>
renderRow(
logs[virtualRow.index],
virtualRow.index,
virtualRow,
),
)}
{logs.length === 0 && !loading && (
<tr>
<td
colSpan={6}
className={cx(
commonTableCellClass,
"text-center py-8 text-muted-foreground",
)}
>
{t("ui.common.audit.table.no_logs", "No audit logs found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="animate-pulse text-xs text-muted-foreground">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "더 보기")}
</Button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}

View File

@@ -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();
}
}}
/>
</div>
@@ -1529,8 +1534,8 @@ const TenantHierarchyView: React.FC<{
const parentRef = React.useRef<HTMLDivElement>(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({

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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<HTMLInputElement>) => {
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);
}
}}
/>
</div>
@@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
onClick={() => 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<string>("");
const [visibleColumns, setVisibleColumns] = React.useState<
@@ -285,9 +264,6 @@ function UserListPage() {
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const userTableViewportRef = React.useRef<HTMLDivElement | null>(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<UserSummary, UserSortKey>
>(
@@ -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={
<>
<UserListSearchControls
search={search}
initialSearch={search}
selectedCompany={selectedCompany}
tenants={tenants}
profileRole={profile?.role}
profileRole={profileRole}
onSearch={handleSearch}
onCompanyChange={handleCompanyChange}
/>
@@ -1241,36 +1232,6 @@ function UserListPage() {
</Button>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
{t("ui.common.previous", "Previous")}
</Button>
<div className="text-sm text-muted-foreground">
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
page,
total: totalPages,
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
{t("ui.common.next", "Next")}
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -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<AuditLogListResponse>("/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<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search, tenantSlug },
params: { limit, offset, search, tenantSlug, cursor },
});
return data;
}

View File

@@ -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<string>();
// 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 };
}

View File

@@ -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 });
});
});

View File

@@ -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,
});
});

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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")
}

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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 != "" {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 = ""