diff --git a/openrag/api/routers/admin/partitions.py b/openrag/api/routers/admin/partitions.py index 1f098b78d..224e1fad8 100644 --- a/openrag/api/routers/admin/partitions.py +++ b/openrag/api/routers/admin/partitions.py @@ -383,8 +383,10 @@ async def get_partition_config( **Response:** Returns list of partition members with: - `user_id`: User identifier +- `display_name`: Human-readable name, when available +- `email`: Account email, when available - `role`: User's role (owner, editor, or viewer) -- Additional user details +- `added_at`: Membership creation time **Permissions:** - Requires partition owner role @@ -401,7 +403,7 @@ async def list_partition_users( service=Depends(get_partition_service), ): """List all users who are members of the given partition.""" - members = await service.list_members(partition=partition) + members = await service.list_members_with_identities(partition=partition) return JSONResponse(status_code=status.HTTP_200_OK, content={"members": members}) diff --git a/openrag/api/routers/admin/users.py b/openrag/api/routers/admin/users.py index bc3b2b09e..bc86c0a27 100644 --- a/openrag/api/routers/admin/users.py +++ b/openrag/api/routers/admin/users.py @@ -38,6 +38,7 @@ - `id`: User identifier - `display_name`: User's display name - `external_user_id`: External ID (if set) +- `email`: Account email (if set) - `is_admin`: Admin status - `created_at`: Account creation timestamp @@ -141,6 +142,7 @@ async def create_user( - `id`: User identifier - `display_name`: User's display name - `external_user_id`: External ID (if set) +- `email`: Account email (if set) - `is_admin`: Admin status - `created_at`: Account creation timestamp diff --git a/openrag/core/ports/user_repo.py b/openrag/core/ports/user_repo.py index 747eadf0c..dc0dd2e19 100644 --- a/openrag/core/ports/user_repo.py +++ b/openrag/core/ports/user_repo.py @@ -30,6 +30,9 @@ async def create_user(self, user: User) -> User: ... @abstractmethod async def get_user(self, user_id: int) -> User | None: ... + @abstractmethod + async def get_users_by_ids(self, user_ids: list[int]) -> list[User]: ... + @abstractmethod async def get_user_by_email(self, email: str) -> User | None: ... diff --git a/openrag/services/orchestrators/partition_service.py b/openrag/services/orchestrators/partition_service.py index b0204a9e4..f06ca2901 100644 --- a/openrag/services/orchestrators/partition_service.py +++ b/openrag/services/orchestrators/partition_service.py @@ -673,9 +673,22 @@ def _meta(row: dict[str, Any]) -> dict[str, Any]: # ------------------------------------------------------------------ async def list_members(self, partition: str) -> list[dict]: + """Return role data without identity lookups for authorization callers.""" await self._ensure_partition(partition) return await self._membership_repo.list_partition_members(partition) + async def list_members_with_identities(self, partition: str) -> list[dict]: + """Enrich the admin-facing member list with one bulk user lookup.""" + members = await self.list_members(partition) + users = { + user.id: user for user in await self._user_repo.get_users_by_ids([member["user_id"] for member in members]) + } + for member in members: + user = users.get(member["user_id"]) + member["display_name"] = user.display_name if user else None + member["email"] = user.email if user else None + return members + async def add_member(self, partition: str, user_id: int, role: str) -> None: await self._ensure_partition(partition) await self._ensure_user_exists(user_id) diff --git a/openrag/services/persistence/user_repo.py b/openrag/services/persistence/user_repo.py index 88206850b..21fc3ea11 100644 --- a/openrag/services/persistence/user_repo.py +++ b/openrag/services/persistence/user_repo.py @@ -102,6 +102,15 @@ async def get_user(self, user_id: int) -> User | None: memberships = await self._fetch_memberships(user_id) return self._row_to_user(row, memberships) + async def get_users_by_ids(self, user_ids: list[int]) -> list[User]: + if not user_ids: + return [] + rows = await self.pool.fetch( + "SELECT * FROM users WHERE id = ANY($1::int[])", + user_ids, + ) + return [self._row_to_user(row) for row in rows] + async def get_user_by_email(self, email: str) -> User | None: row = await self.pool.fetchrow( "SELECT * FROM users WHERE email = $1", @@ -332,6 +341,7 @@ async def list_users_dict(self) -> list[dict]: "id": r["id"], "display_name": r["display_name"], "external_user_id": r["external_user_id"], + "email": r["email"], "is_admin": r["is_admin"], "file_quota": r["file_quota"], "file_count": r["file_count"], diff --git a/tests/unit/services/orchestrators/test_partition_service.py b/tests/unit/services/orchestrators/test_partition_service.py index ca2497aeb..c3059de5f 100644 --- a/tests/unit/services/orchestrators/test_partition_service.py +++ b/tests/unit/services/orchestrators/test_partition_service.py @@ -212,12 +212,41 @@ async def query_chunks_by_filter(self, collection, filters, output_fields=None): class FakeUserRepo: - def __init__(self, existing: set[int] | None = None): + def __init__( + self, + existing: set[int] | None = None, + display_names: dict[int, str] | None = None, + emails: dict[int, str] | None = None, + ): self._existing = existing if existing is not None else set() + self._display_names = display_names or {} + self._emails = emails or {} + self.requested_user_id_batches: list[list[int]] = [] async def user_exists(self, user_id: int) -> bool: return user_id in self._existing + async def get_user(self, user_id: int): + if user_id not in self._existing: + return None + return SimpleNamespace( + id=user_id, + display_name=self._display_names.get(user_id), + email=self._emails.get(user_id), + ) + + async def get_users_by_ids(self, user_ids: list[int]): + self.requested_user_id_batches.append(list(user_ids)) + return [ + SimpleNamespace( + id=user_id, + display_name=self._display_names.get(user_id), + email=self._emails.get(user_id), + ) + for user_id in user_ids + if user_id in self._existing + ] + def _svc( *, @@ -830,6 +859,56 @@ async def test_list_members_missing_partition_404(): await _svc(prepo=FakePartitionRepo(set())).list_members("x") +@pytest.mark.asyncio +async def test_list_members_does_not_lookup_user_identities(): + mrepo = FakeMembershipRepo(members={(9, "p")}) + urepo = FakeUserRepo({9}) + svc = _svc(prepo=FakePartitionRepo({"p"}), mrepo=mrepo, urepo=urepo) + + members = await svc.list_members("p") + + assert members == [{"user_id": 9, "role": "viewer"}] + assert urepo.requested_user_id_batches == [] + + +@pytest.mark.asyncio +async def test_list_members_with_identities_uses_one_lookup(): + mrepo = FakeMembershipRepo(members={(9, "p"), (10, "p")}) + urepo = FakeUserRepo( + {9, 10}, + display_names={9: "Alice", 10: "Bob"}, + emails={9: "alice@example.com", 10: "bob@example.com"}, + ) + svc = _svc(prepo=FakePartitionRepo({"p"}), mrepo=mrepo, urepo=urepo) + members = await svc.list_members_with_identities("p") + assert {member["user_id"]: member for member in members} == { + 9: { + "user_id": 9, + "role": "viewer", + "display_name": "Alice", + "email": "alice@example.com", + }, + 10: { + "user_id": 10, + "role": "viewer", + "display_name": "Bob", + "email": "bob@example.com", + }, + } + assert len(urepo.requested_user_id_batches) == 1 + assert set(urepo.requested_user_id_batches[0]) == {9, 10} + + +@pytest.mark.asyncio +async def test_list_members_missing_user_display_name_is_none(): + mrepo = FakeMembershipRepo(members={(9, "p")}) + urepo = FakeUserRepo(set()) # user_id 9 no longer exists + svc = _svc(prepo=FakePartitionRepo({"p"}), mrepo=mrepo, urepo=urepo) + members = await svc.list_members_with_identities("p") + assert members[0]["display_name"] is None + assert members[0]["email"] is None + + @pytest.mark.asyncio async def test_add_member_checks_partition_and_user(): mrepo = FakeMembershipRepo() diff --git a/tests/unit/services/persistence/test_user_repo_external_id.py b/tests/unit/services/persistence/test_user_repo_external_id.py index f582db6ef..201d38242 100644 --- a/tests/unit/services/persistence/test_user_repo_external_id.py +++ b/tests/unit/services/persistence/test_user_repo_external_id.py @@ -30,10 +30,14 @@ def __init__(self): self.last_query: str | None = None self.last_params: tuple = () self._next_row: _FakeRow | None = None + self._rows: list[_FakeRow] = [] def set_next_row(self, **fields): self._next_row = _FakeRow(fields) + def set_rows(self, *rows: _FakeRow): + self._rows = list(rows) + async def fetchrow(self, query: str, *params): self.last_query = query self.last_params = params @@ -49,7 +53,9 @@ async def execute(self, query: str, *params): async def fetch(self, query: str, *params): self.last_query = query self.last_params = params - return [] + if "partition_memberships" in query: + return [] + return self._rows def _make_user_with_ext(ext: str | None): @@ -141,3 +147,65 @@ async def test_create_legacy_user_coerces_empty_external_id_to_none(): ) # Same column position (display_name, external_user_id, ...) assert pool.last_params[1] is None + + +@pytest.mark.asyncio +async def test_list_users_dict_includes_email(): + from services.persistence.user_repo import PgUserRepository + + pool = _FakePool() + pool.set_rows( + _FakeRow( + id=42, + display_name="Alice", + external_user_id="kc-alice", + email="alice@example.com", + is_admin=False, + file_quota=None, + file_count=0, + created_at=__import__("datetime").datetime(2026, 1, 1), + ) + ) + repo = PgUserRepository(pool_getter=lambda: pool) + + users = await repo.list_users_dict() + + assert users[0]["email"] == "alice@example.com" + + +@pytest.mark.asyncio +async def test_get_users_by_ids_fetches_all_users_in_one_query(): + from services.persistence.user_repo import PgUserRepository + + pool = _FakePool() + pool.set_rows( + _FakeRow( + id=42, + display_name="Alice", + external_user_id="kc-alice", + email="alice@example.com", + token=None, + is_admin=False, + file_quota=None, + file_count=0, + created_at=__import__("datetime").datetime(2026, 1, 1), + ), + _FakeRow( + id=84, + display_name="Bob", + external_user_id="kc-bob", + email="bob@example.com", + token=None, + is_admin=False, + file_quota=None, + file_count=0, + created_at=__import__("datetime").datetime(2026, 1, 2), + ), + ) + repo = PgUserRepository(pool_getter=lambda: pool) + + users = await repo.get_users_by_ids([42, 84]) + + assert {user.id for user in users} == {42, 84} + assert pool.last_params == ([42, 84],) + assert "ANY($1::int[])" in (pool.last_query or "") diff --git a/ui/src/components/shared/data-table.tsx b/ui/src/components/shared/data-table.tsx index 71fb6668f..2a19d0a10 100644 --- a/ui/src/components/shared/data-table.tsx +++ b/ui/src/components/shared/data-table.tsx @@ -28,7 +28,10 @@ interface BaseDataTableProps { columns: ColumnDef[]; data: TData[]; pageSize?: number; + emptyMessage?: string; initialSorting?: SortingState; + /** Reset pagination to the first page whenever this value changes. */ + pageResetKey?: unknown; /** Render a leading checkbox column. */ enableSelection?: boolean; /** Optional row-level selection guard for pages with state-dependent bulk actions. */ @@ -54,7 +57,9 @@ export function DataTable({ columns, data, pageSize = 10, + emptyMessage = "No results.", initialSorting = [], + pageResetKey, enableSelection = false, canSelectRow, getRowId, @@ -68,6 +73,12 @@ export function DataTable({ const rowSelection = controlledRowSelection ?? internalRowSelection; const setRowSelection = onRowSelectionChange ?? setInternalRowSelection; + useEffect(() => { + setPagination((previous) => + previous.pageIndex === 0 ? previous : { ...previous, pageIndex: 0 }, + ); + }, [pageResetKey]); + // Prepend a checkbox column when selection is enabled. const tableColumns = useMemo[]>(() => { if (!enableSelection) return columns; @@ -177,7 +188,7 @@ export function DataTable({ colSpan={columnCount} className="h-24 text-center text-muted-foreground" > - No results. + {emptyMessage} )} diff --git a/ui/src/lib/api/partitions.ts b/ui/src/lib/api/partitions.ts index 13743a141..f0d68087a 100644 --- a/ui/src/lib/api/partitions.ts +++ b/ui/src/lib/api/partitions.ts @@ -8,7 +8,7 @@ import { request } from "./client"; // POST /partition/{p} create (name in path, NO body; caller becomes owner) → 201 // PATCH /partition/{p} update config → PartitionDetailResponse // DELETE /partition/{p} delete → 204 -// GET /partition/{p}/users members → { members: [{ user_id, role, added_at }] } +// GET /partition/{p}/users members → { members: [{ user_id, display_name, email, role, added_at }] } // POST /partition/{p}/users add member (multipart: user_id, role) // PATCH /partition/{p}/users/{user_id} change role (multipart: role) // DELETE /partition/{p}/users/{user_id} remove member @@ -194,6 +194,8 @@ export function listPartitionFiles(name: string, limit?: number): Promise<{ file export interface PartitionMember { user_id: number; + display_name: string | null; + email: string | null; role: PartitionRole; added_at: string | null; } diff --git a/ui/src/pages/admin/partitions/detail.tsx b/ui/src/pages/admin/partitions/detail.tsx index 704573d3f..3b0dc08b7 100644 --- a/ui/src/pages/admin/partitions/detail.tsx +++ b/ui/src/pages/admin/partitions/detail.tsx @@ -51,6 +51,11 @@ import { listPresets } from "@/lib/api/presets"; import { listModelEndpoints, validateStoredModelEndpoint, resolveEmbedderName } from "@/lib/api/models"; import { usePermissions } from "@/lib/permissions"; import { formatDate, intOr } from "@/lib/utils"; +import { + PartitionMemberEmail, + PartitionMemberIdentity, +} from "./partition-member-identity"; +import { describePartitionMember } from "./partition-member"; // --- General Tab --- @@ -463,11 +468,12 @@ function UsersTab({ partitionName }: { partitionName: string }) { ))} ) : usersQuery.data && usersQuery.data.members.length > 0 ? ( -
+
- User ID + User + Email Role Added {canManage && Actions} @@ -476,8 +482,11 @@ function UsersTab({ partitionName }: { partitionName: string }) { {usersQuery.data.members.map((user) => ( - - {user.user_id} + + + + + {canManage ? ( @@ -508,13 +517,14 @@ function UsersTab({ partitionName }: { partitionName: string }) { removeMutation.mutate(user.user_id)} > diff --git a/ui/src/pages/admin/partitions/partition-member-identity.test.tsx b/ui/src/pages/admin/partitions/partition-member-identity.test.tsx new file mode 100644 index 000000000..2abe9928c --- /dev/null +++ b/ui/src/pages/admin/partitions/partition-member-identity.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { PartitionMember } from "@/lib/api/partitions"; +import { + PartitionMemberEmail, + PartitionMemberIdentity, +} from "./partition-member-identity"; +import { describePartitionMember } from "./partition-member"; + +function member(overrides: Partial = {}): PartitionMember { + return { + user_id: 9, + display_name: "Alice", + email: "alice@example.com", + role: "viewer", + added_at: null, + ...overrides, + }; +} + +describe("PartitionMemberIdentity", () => { + it("shows the display name and stable user ID", () => { + render(); + + expect(screen.getByText("Alice")).not.toBeNull(); + expect(screen.getByText("User ID 9")).not.toBeNull(); + }); + + it("retains a useful identity when the display name is missing", () => { + render(); + + expect(screen.getByText("User 9")).not.toBeNull(); + expect(screen.getByText("User ID 9")).not.toBeNull(); + }); + + it("shows email explicitly with a clear missing-value state", () => { + const { rerender } = render(); + + expect(screen.getByText("alice@example.com")).not.toBeNull(); + + rerender(); + + expect(screen.getByText("Not available")).not.toBeNull(); + }); + + it("describes a member unambiguously in destructive actions", () => { + expect(describePartitionMember(member())).toBe( + "Alice, alice@example.com (user ID 9)", + ); + expect(describePartitionMember(member({ display_name: null, email: null }))).toBe( + "user ID 9", + ); + }); +}); diff --git a/ui/src/pages/admin/partitions/partition-member-identity.tsx b/ui/src/pages/admin/partitions/partition-member-identity.tsx new file mode 100644 index 000000000..bd5a8d714 --- /dev/null +++ b/ui/src/pages/admin/partitions/partition-member-identity.tsx @@ -0,0 +1,26 @@ +import type { PartitionMember } from "@/lib/api/partitions"; + +export function PartitionMemberIdentity({ member }: { member: PartitionMember }) { + const primaryIdentity = member.display_name || `User ${member.user_id}`; + + return ( +
+
+ {primaryIdentity} +
+
User ID {member.user_id}
+
+ ); +} + +export function PartitionMemberEmail({ member }: { member: PartitionMember }) { + if (!member.email) { + return Not available; + } + + return ( + + {member.email} + + ); +} diff --git a/ui/src/pages/admin/partitions/partition-member.ts b/ui/src/pages/admin/partitions/partition-member.ts new file mode 100644 index 000000000..05223240e --- /dev/null +++ b/ui/src/pages/admin/partitions/partition-member.ts @@ -0,0 +1,8 @@ +import type { PartitionMember } from "@/lib/api/partitions"; + +export function describePartitionMember(member: PartitionMember): string { + const knownIdentity = [member.display_name, member.email].filter(Boolean).join(", "); + return knownIdentity + ? `${knownIdentity} (user ID ${member.user_id})` + : `user ID ${member.user_id}`; +} diff --git a/ui/src/pages/admin/users/list.test.tsx b/ui/src/pages/admin/users/list.test.tsx index 757aa936a..c44902962 100644 --- a/ui/src/pages/admin/users/list.test.tsx +++ b/ui/src/pages/admin/users/list.test.tsx @@ -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", () => ({ @@ -28,13 +30,30 @@ vi.mock("@/lib/api/users", async () => { const listUsersMock = vi.mocked(listUsers); -function renderUsers() { +function makeUser(overrides: Partial = {}): 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(cachedUsers?: UserResponse[]) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); + if (cachedUsers) { + queryClient.setQueryData(["users"], { users: cachedUsers }); + } return render( @@ -47,19 +66,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()], }); }); @@ -72,4 +81,132 @@ 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(); + + await userEvent.click(screen.getByRole("button", { name: "Clear user search" })); + await userEvent.type(search, "User #12"); + + expect(await screen.findByText("Zara Operator")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe("1 of 11 users"); + }); + + 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("returns to the first page when search changes or is cleared", async () => { + const users = Array.from({ length: 30 }, (_, index) => + makeUser({ + id: index + 2, + display_name: `Match ${index + 1}`, + external_user_id: `subject-${index + 2}`, + email: `user-${index + 2}@example.test`, + }), + ); + listUsersMock.mockResolvedValue({ users }); + + renderUsers(); + + expect(await screen.findByText("Page 1 of 3")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "Next page" })); + await userEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(screen.getByText("Page 3 of 3")).toBeTruthy(); + + const search = screen.getByRole("searchbox", { name: "Search users" }); + await userEvent.type(search, "match"); + + expect(await screen.findByText("Page 1 of 3")).toBeTruthy(); + expect(screen.getByText("Match 1")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(screen.getByText("Page 2 of 3")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "Clear user search" })); + + expect(await screen.findByText("Page 1 of 3")).toBeTruthy(); + expect(screen.getByText("Match 1")).toBeTruthy(); + }); + + 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"); + }); + + it("shows a retryable error instead of reporting a failed request as an empty directory", async () => { + listUsersMock.mockRejectedValueOnce(new Error("User service unavailable")); + + renderUsers(); + + expect((await screen.findByRole("alert")).textContent).toContain("Users could not be loaded"); + expect(screen.getByRole("alert").textContent).toContain("User service unavailable"); + expect(screen.queryByText("No users have been created yet.")).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(await screen.findByText("Ada Lovelace")).toBeTruthy(); + }); + + it("keeps cached users visible when a background refresh fails", async () => { + listUsersMock.mockRejectedValueOnce(new Error("Refresh unavailable")); + + renderUsers([makeUser()]); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Users could not be refreshed"); + expect(alert.textContent).toContain("Showing previously loaded users"); + expect(screen.getByText("Ada Lovelace")).toBeTruthy(); + expect(screen.getByRole("searchbox", { name: "Search users" })).toBeTruthy(); + }); }); diff --git a/ui/src/pages/admin/users/list.tsx b/ui/src/pages/admin/users/list.tsx index 21651fb79..64301cbf1 100644 --- a/ui/src/pages/admin/users/list.tsx +++ b/ui/src/pages/admin/users/list.tsx @@ -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, AlertCircle, RefreshCw } 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"; @@ -16,6 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { copyToClipboard } from "@/lib/utils"; import { Dialog, @@ -26,6 +27,40 @@ import { DialogFooter, } from "@/components/ui/dialog"; +const EMPTY_USERS: UserResponse[] = []; + +function UserDirectoryError({ + title, + message, + isRetrying, + onRetry, +}: { + title: string; + message: string; + isRetrying: boolean; + onRetry: () => void; +}) { + return ( + + + ); +} + // "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( @@ -44,8 +79,9 @@ function formatUsage( export default function UserListPage() { const queryClient = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); + const [search, setSearch] = useState(""); - const { data, isLoading } = useQuery({ + const usersQuery = useQuery({ queryKey: ["users"], queryFn: listUsers, }); @@ -55,6 +91,34 @@ 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 = usersQuery.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, + 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()}”.`; + const usersErrorMessage = + usersQuery.error instanceof Error + ? usersQuery.error.message + : "The user directory request failed."; const deleteMut = useMutation({ mutationFn: deleteUser, @@ -164,10 +228,75 @@ export default function UserListPage() { } /> - {isLoading ? ( + {usersQuery.isLoading ? ( + ) : usersQuery.isLoadingError ? ( + { + usersQuery.refetch(); + }} + /> ) : ( - +
+ {usersQuery.isRefetchError && ( + { + usersQuery.refetch(); + }} + /> + )} +
+
+ +
+

+ {resultSummary} +

+
+ +
)}