Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ui/src/components/shared/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface BaseDataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageSize?: number;
emptyMessage?: string;
initialSorting?: SortingState;
/** Render a leading checkbox column. */
enableSelection?: boolean;
Expand All @@ -54,6 +55,7 @@ export function DataTable<TData, TValue>({
columns,
data,
pageSize = 10,
emptyMessage = "No results.",
initialSorting = [],
enableSelection = false,
canSelectRow,
Expand Down Expand Up @@ -177,7 +179,7 @@ export function DataTable<TData, TValue>({
colSpan={columnCount}
className="h-24 text-center text-muted-foreground"
>
No results.
{emptyMessage}
</TableCell>
</TableRow>
)}
Expand Down
94 changes: 82 additions & 12 deletions ui/src/pages/admin/users/list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listUsers } from "@/lib/api/users";
import type { UserResponse } from "@/lib/api/users";
import UserListPage from "./list";

vi.mock("sonner", () => ({
Expand All @@ -28,6 +30,20 @@ vi.mock("@/lib/api/users", async () => {

const listUsersMock = vi.mocked(listUsers);

function makeUser(overrides: Partial<UserResponse> = {}): UserResponse {
return {
id: 2,
display_name: "Ada Lovelace",
external_user_id: "ada",
email: "ada@example.test",
is_admin: false,
file_quota: null,
file_count: 0,
created_at: null,
...overrides,
};
}

function renderUsers() {
const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -47,19 +63,9 @@ function renderUsers() {

describe("UserListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
listUsersMock.mockResolvedValue({
users: [
{
id: 2,
display_name: "Ada Lovelace",
external_user_id: "ada",
email: "ada@example.test",
is_admin: false,
file_quota: null,
file_count: 0,
created_at: null,
},
],
users: [makeUser()],
});
});

Expand All @@ -72,4 +78,68 @@ describe("UserListPage", () => {
expect(view.getAttribute("data-size")).toBe("icon-xs");
expect(deleteAction.getAttribute("data-size")).toBe("icon-xs");
});

it("searches visible identifiers across paginated rows", async () => {
const users = Array.from({ length: 10 }, (_, index) =>
makeUser({
id: index + 2,
display_name: `User ${index + 2}`,
external_user_id: `subject-${index + 2}`,
email: `user-${index + 2}@example.test`,
}),
);
users.push(
makeUser({
id: 12,
display_name: "Zara Operator",
external_user_id: "oidc-zara",
email: "zara@example.test",
}),
);
listUsersMock.mockResolvedValue({ users });

renderUsers();

const search = await screen.findByRole("searchbox", { name: "Search users" });
expect(screen.queryByText("Zara Operator")).toBeNull();

await userEvent.type(search, "ZARA@EXAMPLE.TEST");

expect(await screen.findByText("Zara Operator")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe("1 of 11 users");
expect(listUsersMock).toHaveBeenCalledTimes(1);

await userEvent.click(screen.getByRole("button", { name: "Clear user search" }));
await userEvent.type(search, "oidc-zara");

expect(await screen.findByText("Zara Operator")).toBeTruthy();

await userEvent.click(screen.getByRole("button", { name: "Clear user search" }));
await userEvent.type(search, "zArA operator");

expect(await screen.findByText("Zara Operator")).toBeTruthy();
});

it("shows a clear no-result state and restores the list when search is cleared", async () => {
renderUsers();

const search = await screen.findByRole("searchbox", { name: "Search users" });
await userEvent.type(search, "missing account");

expect(screen.getByText("No users match “missing account”.")).toBeTruthy();

await userEvent.click(screen.getByRole("button", { name: "Clear user search" }));

expect(await screen.findByText("Ada Lovelace")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe("1 user");
});

it("distinguishes an empty directory from an unsuccessful search", async () => {
listUsersMock.mockResolvedValue({ users: [] });

renderUsers();

expect(await screen.findByText("No users have been created yet.")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe("0 users");
});
});
74 changes: 71 additions & 3 deletions ui/src/pages/admin/users/list.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { Trash2, Eye, Plus, Copy } from "lucide-react";
import { Trash2, Eye, Plus, Copy, Search, X } from "lucide-react";
import { listUsers, deleteUser, createUser, effectiveQuota } from "@/lib/api/users";
import type { UserResponse, UserWithToken } from "@/lib/api/users";
import { getConfig } from "@/lib/api/system";
Expand All @@ -26,6 +26,8 @@ import {
DialogFooter,
} from "@/components/ui/dialog";

const EMPTY_USERS: UserResponse[] = [];

// "indexed / effective-quota", e.g. "11 / 200". ∞ for unlimited; `over` flags
// users at or past their cap (shown in red) so admins spot blocked uploaders.
function formatUsage(
Expand All @@ -44,6 +46,7 @@ function formatUsage(
export default function UserListPage() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [search, setSearch] = useState("");

const { data, isLoading } = useQuery({
queryKey: ["users"],
Expand All @@ -55,6 +58,30 @@ export default function UserListPage() {
const { data: config } = useQuery({ queryKey: ["system-config"], queryFn: getConfig });
const globalDefault =
(config?.rdb as { default_file_quota?: number } | undefined)?.default_file_quota ?? null;
const users = data?.users ?? EMPTY_USERS;
const normalizedSearch = search.trim().toLowerCase();
const filteredUsers = useMemo(() => {
if (!normalizedSearch) return users;
return users.filter((user) =>
[
user.display_name,
user.external_user_id,
user.email,
Comment thread
hedhoud marked this conversation as resolved.
Resolved
String(user.id),
`User #${user.id}`,
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedSearch)),
);
}, [normalizedSearch, users]);
const totalUsersLabel = `${users.length} ${users.length === 1 ? "user" : "users"}`;
const resultSummary = normalizedSearch
? `${filteredUsers.length} of ${totalUsersLabel}`
: totalUsersLabel;
const emptyMessage =
users.length === 0
? "No users have been created yet."
: `No users match “${search.trim()}”.`;
Comment thread
hedhoud marked this conversation as resolved.
Resolved

const deleteMut = useMutation({
mutationFn: deleteUser,
Expand Down Expand Up @@ -167,7 +194,48 @@ export default function UserListPage() {
{isLoading ? (
<Skeleton className="h-64" />
) : (
<DataTable columns={columns} data={data?.users || []} />
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-md">
<Label htmlFor="user-search" className="sr-only">
Search users
</Label>
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="user-search"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, email or external ID"
className="pl-9 pr-10"
/>
{search && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setSearch("")}
className="absolute right-0.5 top-1/2 -translate-y-1/2"
aria-label="Clear user search"
title="Clear search"
>
<X className="h-4 w-4" />
</Button>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</div>
<p
role="status"
aria-live="polite"
className="text-sm text-muted-foreground tabular-nums"
>
{resultSummary}
</p>
</div>
<DataTable columns={columns} data={filteredUsers} emptyMessage={emptyMessage} />
Comment thread
hedhoud marked this conversation as resolved.
Resolved
</div>
)}

<PreProvisionDialog open={dialogOpen} onOpenChange={setDialogOpen} />
Expand Down
Loading