diff --git a/backend/oasst_backend/user_repository.py b/backend/oasst_backend/user_repository.py index b467bcaf..136d3d29 100644 --- a/backend/oasst_backend/user_repository.py +++ b/backend/oasst_backend/user_repository.py @@ -207,6 +207,7 @@ class UserRepository: limit: Optional[int] = 100, desc: bool = False, ) -> list[User]: + if not self.api_client.trusted: if not api_client_id: # Let unprivileged api clients query their own users without api_client_id being set diff --git a/website/package-lock.json b/website/package-lock.json index 5c5dc795..0d38e98c 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -21,6 +21,7 @@ "@next/font": "^13.1.0", "@prisma/client": "^4.7.1", "@tailwindcss/forms": "^0.5.3", + "@tanstack/react-table": "^8.7.6", "accept-language-parser": "^1.5.0", "autoprefixer": "^10.4.13", "axios": "^1.2.1", @@ -12299,6 +12300,37 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, + "node_modules/@tanstack/react-table": { + "version": "8.7.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.6.tgz", + "integrity": "sha512-/QijmMFeP7wDLBnr0MQ/5MlbXePbIL/1nOtkxBC9zvmBu4gDKJEDBqipUyM7Wc/iBpSd0IFyqBlvZvTPD9FYDA==", + "dependencies": { + "@tanstack/table-core": "8.7.6" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.7.6", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.6.tgz", + "integrity": "sha512-sqiNTMzB6cpyL8DFH6/VqW48SwiflLqxQqYpo2wNock7rdVGvlm0BLNI8vZUJbr1+fmmWmHwBvi5OMgZw8n1DA==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.19.1.tgz", @@ -46706,6 +46738,19 @@ "mini-svg-data-uri": "^1.2.3" } }, + "@tanstack/react-table": { + "version": "8.7.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.6.tgz", + "integrity": "sha512-/QijmMFeP7wDLBnr0MQ/5MlbXePbIL/1nOtkxBC9zvmBu4gDKJEDBqipUyM7Wc/iBpSd0IFyqBlvZvTPD9FYDA==", + "requires": { + "@tanstack/table-core": "8.7.6" + } + }, + "@tanstack/table-core": { + "version": "8.7.6", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.6.tgz", + "integrity": "sha512-sqiNTMzB6cpyL8DFH6/VqW48SwiflLqxQqYpo2wNock7rdVGvlm0BLNI8vZUJbr1+fmmWmHwBvi5OMgZw8n1DA==" + }, "@testing-library/dom": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.19.1.tgz", diff --git a/website/package.json b/website/package.json index 8866a9e2..e4fb8319 100644 --- a/website/package.json +++ b/website/package.json @@ -38,6 +38,7 @@ "@next/font": "^13.1.0", "@prisma/client": "^4.7.1", "@tailwindcss/forms": "^0.5.3", + "@tanstack/react-table": "^8.7.6", "accept-language-parser": "^1.5.0", "autoprefixer": "^10.4.13", "axios": "^1.2.1", diff --git a/website/src/components/DataTable.tsx b/website/src/components/DataTable.tsx new file mode 100644 index 00000000..f9ef4e49 --- /dev/null +++ b/website/src/components/DataTable.tsx @@ -0,0 +1,166 @@ +import { + Box, + Button, + Card, + CardBody, + Flex, + FormControl, + FormLabel, + Input, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, + Spacer, + Table, + TableCaption, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, + useDisclosure, +} from "@chakra-ui/react"; +import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { ChangeEvent, ReactNode } from "react"; +import { FaFilter } from "react-icons/fa"; +import { useDebouncedCallback } from "use-debounce"; + +export type DataTableColumnDef = ColumnDef & { + filterable?: boolean; +}; + +// TODO: stricter type +export type FilterItem = { + id: string; + value: string; +}; + +export type DataTableProps = { + data: T[]; + columns: DataTableColumnDef[]; + caption?: string; + filterValues?: FilterItem[]; + onNextClick?: () => void; + onPreviousClick?: () => void; + onFilterChange?: (items: FilterItem[]) => void; + disableNext?: boolean; + disablePrevious?: boolean; +}; + +export const DataTable = ({ + data, + columns, + caption, + filterValues = [], + onNextClick, + onPreviousClick, + onFilterChange, + disableNext, + disablePrevious, +}: DataTableProps) => { + const { getHeaderGroups, getRowModel } = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const handleFilterChange = (value: FilterItem) => { + const idx = filterValues.findIndex((oldValue) => oldValue.id === value.id); + let newValues: FilterItem[] = []; + if (idx === -1) { + newValues = [...filterValues, value]; + } else { + newValues = filterValues.map((oldValue) => (oldValue.id === value.id ? value : oldValue)); + } + onFilterChange(newValues); + }; + return ( + + + + + + + + + + {caption} + + {getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + {(header.column.columnDef as DataTableColumnDef).filterable && ( + value.id === header.id)?.value ?? ""} + onChange={(value) => handleFilterChange({ id: header.id, value })} + label={flexRender(header.column.columnDef.header, header.getContext())} + > + )} + +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+
+
+ ); +}; + +const FilterModal = ({ + label, + onChange, + value, +}: { + label: ReactNode; + onChange: (val: string) => void; + value: string; +}) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + + const handleInputChange = useDebouncedCallback((e: ChangeEvent) => { + onChange(e.target.value); + }, 500); + + return ( + + + + + + + + + + {label} + + + + + + ); +}; diff --git a/website/src/components/UserTable.tsx b/website/src/components/UserTable.tsx new file mode 100644 index 00000000..df412bbc --- /dev/null +++ b/website/src/components/UserTable.tsx @@ -0,0 +1,108 @@ +import { IconButton } from "@chakra-ui/react"; +import { createColumnHelper } from "@tanstack/react-table"; +import Link from "next/link"; +import { memo, useState } from "react"; +import { FaPen } from "react-icons/fa"; +import { get } from "src/lib/api"; +import { FetchUsersResponse } from "src/lib/oasst_api_client"; +import type { User } from "src/types/Users"; +import useSWR from "swr"; + +import { DataTable, DataTableColumnDef, FilterItem } from "./DataTable"; + +interface Pagination { + /** + * The user's `display_name` used for pagination. + */ + cursor: string; + + /** + * The pagination direction. + */ + direction: "forward" | "back"; +} + +const columnHelper = createColumnHelper(); + +const columns: DataTableColumnDef[] = [ + columnHelper.accessor("user_id", { + header: "ID", + }), + columnHelper.accessor("id", { + header: "Auth ID", + }), + columnHelper.accessor("auth_method", { + header: "Auth Method", + }), + { + ...columnHelper.accessor("display_name", { + header: "Name", + }), + filterable: true, + }, + columnHelper.accessor("role", { + header: "Role", + }), + columnHelper.accessor((user) => user.user_id, { + cell: ({ getValue }) => ( + } + > + ), + header: "Update", + }), +]; + +export const UserTable = memo(function UserTable() { + const [pagination, setPagination] = useState({ cursor: "", direction: "forward" }); + const [filterValues, setFilterValues] = useState([]); + const handleFilterValuesChange = (values: FilterItem[]) => { + setFilterValues(values); + setPagination((old) => ({ ...old, cursor: "" })); + }; + // Fetch and save the users. + // This follows useSWR's recommendation for simple pagination: + // https://swr.vercel.app/docs/pagination#when-to-use-useswr + const display_name = filterValues.find((value) => value.id === "display_name")?.value ?? ""; + const { data, error } = useSWR>( + `/api/admin/users?direction=${pagination.direction}&cursor=${pagination.cursor}&searchDisplayName=${display_name}&sortKey=display_name`, + get, + { + keepPreviousData: true, + } + ); + + const toPreviousPage = () => { + setPagination({ + cursor: data.prev, + direction: "back", + }); + }; + + const toNextPage = () => { + setPagination({ + cursor: data.next, + direction: "forward", + }); + }; + + return ( + <> + + {error && "Unable to load users."} + + ); +}); diff --git a/website/src/components/UsersCell.tsx b/website/src/components/UsersCell.tsx deleted file mode 100644 index 99824090..00000000 --- a/website/src/components/UsersCell.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - Button, - Flex, - Spacer, - Stack, - Table, - TableCaption, - TableContainer, - Tbody, - Td, - Th, - Thead, - Tr, - useToast, -} from "@chakra-ui/react"; -import Link from "next/link"; -import { useState } from "react"; -import { get } from "src/lib/api"; -import type { User } from "src/types/Users"; -import useSWR from "swr"; - -interface Pagination { - /** - * The user's `display_name` used for pagination. - */ - cursor: string; - - /** - * The pagination direction. - */ - direction: "forward" | "back"; -} - -/** - * Fetches users from the users api route and then presents them in a simple Chakra table. - */ -const UsersCell = () => { - const toast = useToast(); - const [pagination, setPagination] = useState({ cursor: "", direction: "forward" }); - const [users, setUsers] = useState([]); - - // Fetch and save the users. - // This follows useSWR's recommendation for simple pagination: - // https://swr.vercel.app/docs/pagination#when-to-use-useswr - useSWR(`/api/admin/users?direction=${pagination.direction}&cursor=${pagination.cursor}`, get, { - onSuccess: (data) => { - // When no more users can be found, trigger a toast to indicate why no - // changes have taken place. We have to maintain a non-empty set of - // users otherwise we can't paginate using a cursor (since we've lost the - // cursor). - if (data.length === 0) { - toast({ - title: "No more users", - status: "warning", - duration: 1000, - isClosable: true, - }); - return; - } - setUsers(data); - }, - }); - - const toPreviousPage = () => { - if (users.length >= 0) { - setPagination({ - cursor: users[0].display_name, - direction: "back", - }); - } else { - toast({ - title: "Can not paginate when no users are found", - status: "warning", - duration: 1000, - isClosable: true, - }); - } - }; - - const toNextPage = () => { - if (users.length >= 0) { - setPagination({ - cursor: users[users.length - 1].display_name, - direction: "forward", - }); - } else { - toast({ - title: "Can not paginate when no users are found", - status: "warning", - duration: 1000, - isClosable: true, - }); - } - }; - - // Present users in a naive table. - return ( - - - - - - - - - Users - - - - - - - - - - - - {users.map(({ id, user_id, auth_method, display_name, role }) => ( - - - - - - - - - ))} - -
IdAuth IdAuth MethodNameRoleUpdate
{user_id}{id}{auth_method}{display_name}{role} - Manage -
-
-
- ); -}; - -export default UsersCell; diff --git a/website/src/lib/oasst_api_client.ts b/website/src/lib/oasst_api_client.ts index 799760e1..cd5e1abc 100644 --- a/website/src/lib/oasst_api_client.ts +++ b/website/src/lib/oasst_api_client.ts @@ -1,7 +1,7 @@ import type { Message } from "src/types/Conversation"; import { LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard"; import type { AvailableTasks } from "src/types/Task"; -import type { BackendUser, BackendUserCore } from "src/types/Users"; +import type { BackendUser, BackendUserCore, User } from "src/types/Users"; export class OasstError { message: string; @@ -15,6 +15,22 @@ export class OasstError { } } +export type FetchUsersParams = { + limit: number; + cursor?: string; + direction: "forward" | "back"; + searchDisplayName?: string; + sortKey?: "username" | "display_name"; +}; + +export type FetchUsersResponse = { + items: T[]; + next?: string; + prev?: string; + sort_key: "username" | "display_name"; + order: "asc" | "desc"; +}; + export class OasstApiClient { oasstApiUrl: string; oasstApiKey: string; @@ -188,21 +204,40 @@ export class OasstApiClient { * forward. If false and `cursor` is not empty, pages backwards. * @returns {Promise} A Promise that returns an array of `BackendUser` objects. */ - async fetch_users(max_count: number, cursor: string, isForward: boolean): Promise { - const params = new URLSearchParams(); - params.append("max_count", max_count.toString()); + async fetch_users({ + direction, + limit, + cursor, + searchDisplayName, + sortKey = "display_name", + }: FetchUsersParams): Promise { + const params = new URLSearchParams({ + search_text: searchDisplayName, + sort_key: sortKey, + max_count: limit.toString(), + }); // The backend API uses different query parameters depending on the // pagination direction but they both take the same cursor value. // Depending on direction, pick the right query param. if (cursor !== "") { - params.append(isForward ? "gt" : "lt", cursor); + params.append(direction === "forward" ? "gt" : "lt", cursor); } - const BASE_URL = `/api/v1/frontend_users`; + const BASE_URL = `/api/v1/users/cursor`; const url = `${BASE_URL}/?${params.toString()}`; return this.get(url); } + // async fetch_user_by_display_name(name: string): Promise { + // const params = new URLSearchParams({ + // search_text: name, + // }); + + // const endpoint = `/api/v1/frontend_users/by_display_name`; + + // return this.get(`${endpoint}?${params.toString()}`); + // } + /** * Returns the `Message`s associated with `user_id` in the backend. */ diff --git a/website/src/pages/admin/index.tsx b/website/src/pages/admin/index.tsx index f8827049..ede9f59c 100644 --- a/website/src/pages/admin/index.tsx +++ b/website/src/pages/admin/index.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { useSession } from "next-auth/react"; import { useEffect } from "react"; import { getAdminLayout } from "src/components/Layout"; -import UsersCell from "src/components/UsersCell"; +import { UserTable } from "src/components/UserTable"; export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props"; /** @@ -28,7 +28,6 @@ const AdminIndex = () => { } router.push("/"); }, [router, session, status]); - return ( <> @@ -38,7 +37,7 @@ const AdminIndex = () => { content="Conversational AI for everyone. An open source project to create a chat enabled GPT LLM run by LAION and contributors around the world." /> -
{status === "loading" ? "loading..." : }
+
{status === "loading" ? "loading..." : }
); }; diff --git a/website/src/pages/api/admin/users.ts b/website/src/pages/api/admin/users.ts index e600650d..57944cff 100644 --- a/website/src/pages/api/admin/users.ts +++ b/website/src/pages/api/admin/users.ts @@ -1,5 +1,5 @@ import { withRole } from "src/lib/auth"; -import { oasstApiClient } from "src/lib/oasst_api_client"; +import { FetchUsersParams, oasstApiClient } from "src/lib/oasst_api_client"; import prisma from "src/lib/prismadb"; /** @@ -17,10 +17,16 @@ const PAGE_SIZE = 20; * direction. */ const handler = withRole("admin", async (req, res) => { - const { cursor, direction } = req.query; + const { cursor, direction, searchDisplayName = "", sortKey = "username" } = req.query; // First, get all the users according to the backend. - const all_users = await oasstApiClient.fetch_users(PAGE_SIZE, cursor as string, direction === "forward"); + const { items: all_users, ...rest } = await oasstApiClient.fetch_users({ + searchDisplayName: searchDisplayName as FetchUsersParams["searchDisplayName"], + direction: direction as FetchUsersParams["direction"], + limit: PAGE_SIZE, + cursor: cursor as FetchUsersParams["cursor"], + sortKey: sortKey === "username" || sortKey === "display_name" ? sortKey : undefined, + }); // Next, get all the users stored in the web's auth database to fetch their role. const local_user_ids = all_users.map(({ id }) => id); @@ -51,7 +57,10 @@ const handler = withRole("admin", async (req, res) => { }; }); - res.status(200).json(users); + res.status(200).json({ + items: users, + ...rest, + }); }); export default handler;