Admin user management (#1393)

part of #1022

    Allow updating the show_on_leaderboard field.
    Add raw JSON of the user object.
    Add a new user message table.
    Also fixed style issue: hard to see reaction count when the user also reacted.
    Rename MessageTable to MessageConversation.
This commit is contained in:
notmd
2023-02-10 15:16:26 +09:00
committed by GitHub
parent 4ab5f58c88
commit aaa1276bae
26 changed files with 409 additions and 135 deletions
+18
View File
@@ -27,6 +27,7 @@
"axios": "^1.2.1", "axios": "^1.2.1",
"boolean": "^3.2.0", "boolean": "^3.2.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"date-fns": "^2.29.3",
"eslint": "8.29.0", "eslint": "8.29.0",
"eslint-config-next": "13.0.6", "eslint-config-next": "13.0.6",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
@@ -17710,6 +17711,18 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.7", "version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
@@ -51947,6 +51960,11 @@
} }
} }
}, },
"date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
},
"dayjs": { "dayjs": {
"version": "1.11.7", "version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
+1
View File
@@ -45,6 +45,7 @@
"axios": "^1.2.1", "axios": "^1.2.1",
"boolean": "^3.2.0", "boolean": "^3.2.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"date-fns": "^2.29.3",
"eslint": "8.29.0", "eslint": "8.29.0",
"eslint-config-next": "13.0.6", "eslint-config-next": "13.0.6",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
@@ -0,0 +1,10 @@
import { forwardRef, IconButton, IconButtonProps } from "@chakra-ui/react";
import { LucideIcon } from "lucide-react";
export type DataTableActionProps = Omit<IconButtonProps, "icon" | "size"> & { icon: LucideIcon };
// need to use forwardRef from Charka to support `as` props
// https://chakra-ui.com/community/recipes/as-prop
export const DataTableAction = forwardRef<DataTableActionProps, "button">((props: DataTableActionProps, ref) => {
return <IconButton size="sm" {...props} icon={<props.icon size="20"></props.icon>} ref={ref} />;
});
@@ -0,0 +1,40 @@
import { useState } from "react";
export interface CursorPaginationState {
/**
* The user's `display_name` used for pagination.
*/
cursor: string;
/**
* The pagination direction.
*/
direction: "forward" | "back";
}
export const useCursorPagination = () => {
const [pagination, setPagination] = useState<CursorPaginationState>({ cursor: "", direction: "forward" });
const toPreviousPage = (data: undefined | { prev?: string; next?: string }) => {
setPagination({
cursor: data?.prev || "",
direction: "back",
});
};
const toNextPage = (data: undefined | { prev?: string; next?: string }) => {
setPagination({
cursor: data?.next || "",
direction: "forward",
});
};
const resetCursor = () => setPagination((old) => ({ ...old, cursor: "" }));
return {
pagination,
toNextPage,
toPreviousPage,
resetCursor,
};
};
+12
View File
@@ -0,0 +1,12 @@
import { Card, CardBody } from "@chakra-ui/card";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const JsonCard = ({ children }: { children: any }) => {
return (
<Card variant="json">
<CardBody overflowX="auto">
<pre>{JSON.stringify(children, null, 2)}</pre>
</CardBody>
</Card>
);
};
@@ -0,0 +1,110 @@
import { Avatar } from "@chakra-ui/avatar";
import { Badge, Flex } from "@chakra-ui/layout";
import { Tooltip } from "@chakra-ui/react";
import { createColumnHelper } from "@tanstack/table-core";
import { formatDistanceToNow, formatISO9075 } from "date-fns";
import { Eye } from "lucide-react";
import NextLink from "next/link";
import { ROUTES } from "src/lib/routes";
import { Message } from "src/types/Conversation";
import { isKnownEmoji } from "src/types/Emoji";
import { StrictOmit } from "src/types/utils";
import { DataTable, DataTableProps } from "../DataTable/DataTable";
import { DataTableAction } from "../DataTable/DataTableAction";
import { MessageEmojiButton } from "./MessageEmojiButton";
const columnHelper = createColumnHelper<Message>();
const columns = [
columnHelper.accessor("text", {
cell: ({ getValue, row }) => {
const limit = 80;
const text = getValue();
const renderText = text.length > limit ? `${text.slice(0, limit)}...` : text;
return (
<Flex alignItems="center">
<Avatar
size="xs"
mr="2"
src={`${row.original.is_assistant ? "/images/logos/logo.png" : "/images/temp-avatars/av1.jpg"}`}
></Avatar>
{renderText}
{row.original.deleted && (
<Badge colorScheme="red" ml="1">
Deleted
</Badge>
)}
</Flex>
);
},
}),
columnHelper.accessor("lang", {
header: "Language",
cell: ({ getValue }) => <Badge>{getValue()}</Badge>,
}),
columnHelper.accessor("emojis", {
header: "Reactions",
cell: ({ getValue, row }) => {
const emojis = getValue();
emojis["+1"] = emojis["+1"] || 0;
emojis["-1"] = emojis["-1"] || 0;
return (
<Flex gap="2">
{Object.entries(emojis)
.filter(([emoji]) => isKnownEmoji(emoji))
.sort(([emoji]) => -emoji)
.map(([emoji, count]) => {
return (
<MessageEmojiButton
key={emoji}
emoji={{ name: emoji, count }}
checked={row.original.user_emojis.includes(emoji)}
userReacted={false}
userIsAuthor={false}
sx={{
":disabled": {
opacity: 1,
},
}}
/>
);
})}
</Flex>
);
},
}),
columnHelper.accessor("created_date", {
header: "Date",
cell: ({ getValue }) => {
return <DateDiff>{getValue()}</DateDiff>;
},
}),
columnHelper.accessor((row) => row.id, {
header: "Actions",
cell: ({ getValue }) => (
<DataTableAction
as={NextLink}
href={ROUTES.ADMIN_MESSAGE_DETAIL(getValue())}
icon={Eye}
aria-label="View message"
/>
),
}),
];
// TODO move this to somewhere
const DateDiff = ({ children }: { children: string | Date | number }) => {
const date = new Date(children);
const diff = formatDistanceToNow(date, { addSuffix: true });
return (
<Tooltip label={formatISO9075(date)} placement="top">
{diff}
</Tooltip>
);
};
export const AdminMessageTable = (props: StrictOmit<DataTableProps<Message>, "columns">) => {
return <DataTable columns={columns} {...props}></DataTable>;
};
@@ -2,12 +2,12 @@ import { SessionProvider } from "next-auth/react";
import React from "react"; import React from "react";
import { Message } from "src/types/Conversation"; import { Message } from "src/types/Conversation";
import { MessageTable } from "./MessageTable"; import { MessageConversation } from "./MessageConversation";
// eslint-disable-next-line import/no-anonymous-default-export // eslint-disable-next-line import/no-anonymous-default-export
export default { export default {
title: "Messages/MessageTable", title: "Messages/MessageTable",
component: MessageTable, component: MessageConversation,
}; };
const Template = ({ const Template = ({
@@ -21,7 +21,7 @@ const Template = ({
}) => { }) => {
return ( return (
<SessionProvider> <SessionProvider>
<MessageTable messages={messages} enableLink={enableLink} highlightLastMessage={highlightLastMessage} />; <MessageConversation messages={messages} enableLink={enableLink} highlightLastMessage={highlightLastMessage} />;
</SessionProvider> </SessionProvider>
); );
}; };
@@ -2,13 +2,13 @@ import { Stack } from "@chakra-ui/react";
import { MessageTableEntry } from "src/components/Messages/MessageTableEntry"; import { MessageTableEntry } from "src/components/Messages/MessageTableEntry";
import { Message } from "src/types/Conversation"; import { Message } from "src/types/Conversation";
interface MessageTableProps { interface MessageConversationProps {
messages: Message[]; messages: Message[];
enableLink?: boolean; enableLink?: boolean;
highlightLastMessage?: boolean; highlightLastMessage?: boolean;
} }
export function MessageTable({ messages, enableLink, highlightLastMessage }: MessageTableProps) { export function MessageConversation({ messages, enableLink, highlightLastMessage }: MessageConversationProps) {
return ( return (
<Stack spacing="4"> <Stack spacing="4">
{messages.map((message, idx) => ( {messages.map((message, idx) => (
@@ -1,4 +1,4 @@
import { Button } from "@chakra-ui/react"; import { Button, ButtonProps } from "@chakra-ui/react";
import { useHasRole } from "src/hooks/auth/useHasRole"; import { useHasRole } from "src/hooks/auth/useHasRole";
import { MessageEmoji } from "src/types/Conversation"; import { MessageEmoji } from "src/types/Conversation";
import { emojiIcons } from "src/types/Emoji"; import { emojiIcons } from "src/types/Emoji";
@@ -6,10 +6,11 @@ import { emojiIcons } from "src/types/Emoji";
interface MessageEmojiButtonProps { interface MessageEmojiButtonProps {
emoji: MessageEmoji; emoji: MessageEmoji;
checked?: boolean; checked?: boolean;
onClick: () => void; onClick?: () => void;
userIsAuthor: boolean; userIsAuthor: boolean;
disabled?: boolean; disabled?: boolean;
userReacted: boolean; userReacted: boolean;
sx?: ButtonProps["sx"];
} }
export const MessageEmojiButton = ({ export const MessageEmojiButton = ({
@@ -19,6 +20,7 @@ export const MessageEmojiButton = ({
userIsAuthor, userIsAuthor,
disabled, disabled,
userReacted, userReacted,
sx,
}: MessageEmojiButtonProps) => { }: MessageEmojiButtonProps) => {
const EmojiIcon = emojiIcons.get(emoji.name); const EmojiIcon = emojiIcons.get(emoji.name);
const isAdmin = useHasRole("admin"); const isAdmin = useHasRole("admin");
@@ -42,8 +44,9 @@ export const MessageEmojiButton = ({
":hover": { ":hover": {
backgroundColor: isDisabled ? "transparent" : undefined, backgroundColor: isDisabled ? "transparent" : undefined,
}, },
...sx,
}} }}
color={isDisabled ? "gray.500" : undefined} color={isDisabled ? (checked ? "gray.700" : "gray.500") : undefined}
> >
<EmojiIcon style={{ height: "1em" }} /> <EmojiIcon style={{ height: "1em" }} />
{showCount && <span style={{ marginInlineEnd: "0.25em" }}>{emoji.count}</span>} {showCount && <span style={{ marginInlineEnd: "0.25em" }}>{emoji.count}</span>}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Box, Stack, Text, useColorModeValue } from "@chakra-ui/react"; import { Box, Stack, Text, useColorModeValue } from "@chakra-ui/react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useState } from "react"; import { useState } from "react";
import { MessageTable } from "src/components/Messages/MessageTable"; import { MessageConversation } from "src/components/Messages/MessageConversation";
import { TrackedTextarea } from "src/components/Survey/TrackedTextarea"; import { TrackedTextarea } from "src/components/Survey/TrackedTextarea";
import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards"; import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards";
import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskSurveyProps } from "src/components/Tasks/Task";
@@ -44,7 +44,7 @@ export const CreateTask = ({
<TaskHeader taskType={taskType} /> <TaskHeader taskType={taskType} />
{task.type !== TaskType.initial_prompt && ( {task.type !== TaskType.initial_prompt && (
<Box mt="4" borderRadius="lg" bg={cardColor} className="p-3 sm:p-6"> <Box mt="4" borderRadius="lg" bg={cardColor} className="p-3 sm:p-6">
<MessageTable messages={task.conversation.messages} highlightLastMessage /> <MessageConversation messages={task.conversation.messages} highlightLastMessage />
</Box> </Box>
)} )}
</> </>
@@ -1,6 +1,6 @@
import { Box, useColorModeValue } from "@chakra-ui/react"; import { Box, useColorModeValue } from "@chakra-ui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MessageTable } from "src/components/Messages/MessageTable"; import { MessageConversation } from "src/components/Messages/MessageConversation";
import { Sortable } from "src/components/Sortable/Sortable"; import { Sortable } from "src/components/Sortable/Sortable";
import { SurveyCard } from "src/components/Survey/SurveyCard"; import { SurveyCard } from "src/components/Survey/SurveyCard";
import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskSurveyProps } from "src/components/Tasks/Task";
@@ -47,7 +47,7 @@ export const EvaluateTask = ({
<SurveyCard> <SurveyCard>
<TaskHeader taskType={taskType} /> <TaskHeader taskType={taskType} />
<Box mt="4" p="6" borderRadius="lg" bg={cardColor}> <Box mt="4" p="6" borderRadius="lg" bg={cardColor}>
<MessageTable messages={messages} highlightLastMessage /> <MessageConversation messages={messages} highlightLastMessage />
</Box> </Box>
<Sortable <Sortable
items={task[sortables]} items={task[sortables]}
@@ -2,7 +2,7 @@ import { Box, useBoolean, useColorModeValue } from "@chakra-ui/react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LabelInputGroup } from "src/components/Messages/LabelInputGroup"; import { LabelInputGroup } from "src/components/Messages/LabelInputGroup";
import { MessageTable } from "src/components/Messages/MessageTable"; import { MessageConversation } from "src/components/Messages/MessageConversation";
import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards"; import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards";
import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskSurveyProps } from "src/components/Tasks/Task";
import { TaskHeader } from "src/components/Tasks/TaskHeader"; import { TaskHeader } from "src/components/Tasks/TaskHeader";
@@ -57,7 +57,7 @@ export const LabelTask = ({
<> <>
<TaskHeader taskType={taskType} /> <TaskHeader taskType={taskType} />
<Box mt="4" p={[4, 6]} borderRadius="lg" bg={cardColor}> <Box mt="4" p={[4, 6]} borderRadius="lg" bg={cardColor}>
<MessageTable messages={task.conversation.messages} highlightLastMessage /> <MessageConversation messages={task.conversation.messages} highlightLastMessage />
</Box> </Box>
</> </>
<LabelInputGroup <LabelInputGroup
@@ -1,40 +0,0 @@
import { Box, CircularProgress, useColorModeValue } from "@chakra-ui/react";
import { MessageTable } from "src/components/Messages/MessageTable";
import { get } from "src/lib/api";
import useSWR from "swr";
interface UserMessagesCellProps {
/**
* The Web API route to fetch user messages from. By default is
* `/api/messages/users` and fetches the logged in user's messages.
*/
path?: string;
}
/**
* Fetches the messages corresponding to a user and presents them in a table.
*/
const UserMessagesCell = ({ path }: UserMessagesCellProps) => {
const url = path || "/api/messages/user";
const { data: messages, isLoading } = useSWR(url, get, {
refreshInterval: 2000,
});
// TODO(#651): This box coloring and styling is used in multiple places. We
// should factor it into a common ui component.
const boxBgColor = useColorModeValue("white", "gray.700");
const boxAccentColor = useColorModeValue("gray.200", "gray.900");
return (
<Box
backgroundColor={boxBgColor}
boxShadow="base"
dropShadow={boxAccentColor}
borderRadius="xl"
className="p-6 shadow-sm"
>
{isLoading ? <CircularProgress isIndeterminate /> : <MessageTable messages={messages} />}
</Box>
);
};
export { UserMessagesCell };
@@ -1 +0,0 @@
export * from "./UserMessagesCell";
+6 -30
View File
@@ -8,18 +8,7 @@ import type { FetchUsersResponse, User } from "src/types/Users";
import useSWR from "swr"; import useSWR from "swr";
import { DataTable, DataTableColumnDef, FilterItem } from "./DataTable/DataTable"; import { DataTable, DataTableColumnDef, FilterItem } from "./DataTable/DataTable";
import { useCursorPagination } from "./DataTable/useCursorPagination";
interface Pagination {
/**
* The user's `display_name` used for pagination.
*/
cursor: string;
/**
* The pagination direction.
*/
direction: "forward" | "back";
}
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
@@ -56,12 +45,13 @@ const columns: DataTableColumnDef<User>[] = [
]; ];
export const UserTable = memo(function UserTable() { export const UserTable = memo(function UserTable() {
const [pagination, setPagination] = useState<Pagination>({ cursor: "", direction: "forward" }); const { pagination, resetCursor, toNextPage, toPreviousPage } = useCursorPagination();
const [filterValues, setFilterValues] = useState<FilterItem[]>([]); const [filterValues, setFilterValues] = useState<FilterItem[]>([]);
const handleFilterValuesChange = (values: FilterItem[]) => { const handleFilterValuesChange = (values: FilterItem[]) => {
setFilterValues(values); setFilterValues(values);
setPagination((old) => ({ ...old, cursor: "" })); resetCursor();
}; };
// Fetch and save the users. // Fetch and save the users.
// This follows useSWR's recommendation for simple pagination: // This follows useSWR's recommendation for simple pagination:
// https://swr.vercel.app/docs/pagination#when-to-use-useswr // https://swr.vercel.app/docs/pagination#when-to-use-useswr
@@ -74,20 +64,6 @@ export const UserTable = memo(function UserTable() {
} }
); );
const toPreviousPage = () => {
setPagination({
cursor: data?.prev || "",
direction: "back",
});
};
const toNextPage = () => {
setPagination({
cursor: data?.next || "",
direction: "forward",
});
};
return ( return (
<Card> <Card>
<CardBody> <CardBody>
@@ -95,8 +71,8 @@ export const UserTable = memo(function UserTable() {
data={data?.items || []} data={data?.items || []}
columns={columns} columns={columns}
caption="Users" caption="Users"
onNextClick={toNextPage} onNextClick={() => toNextPage(data)}
onPreviousClick={toPreviousPage} onPreviousClick={() => toPreviousPage(data)}
disableNext={!data?.next} disableNext={!data?.next}
disablePrevious={!data?.prev} disablePrevious={!data?.prev}
filterValues={filterValues} filterValues={filterValues}
+25 -3
View File
@@ -1,4 +1,4 @@
import type { EmojiOp, Message } from "src/types/Conversation"; import type { EmojiOp, FetchUserMessagesCursorResponse, Message } from "src/types/Conversation";
import { LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard"; import { LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard";
import type { AvailableTasks } from "src/types/Task"; import type { AvailableTasks } from "src/types/Task";
import { FetchTrollBoardResponse, TrollboardTimeFrame } from "src/types/Trollboard"; import { FetchTrollBoardResponse, TrollboardTimeFrame } from "src/types/Trollboard";
@@ -264,11 +264,33 @@ export class OasstApiClient {
return this.get<Message[]>(`/api/v1/users/${user_id}/messages`); return this.get<Message[]>(`/api/v1/users/${user_id}/messages`);
} }
async fetch_user_messages_cursor(
user_id: string,
{
direction,
cursor,
...rest
}: { include_deleted?: boolean; max_count?: number; cursor?: string; direction: "forward" | "back"; desc?: boolean }
) {
return this.get<FetchUserMessagesCursorResponse>(`/api/v1/users/${user_id}/messages/cursor`, {
...rest,
after: direction === "forward" ? cursor : undefined,
before: direction === "back" ? cursor : undefined,
});
}
/** /**
* Updates the backend's knowledge about the `user_id`. * Updates the backend's knowledge about the `user_id`.
*/ */
async set_user_status(user_id: string, is_enabled: boolean, notes: string): Promise<void> { async set_user_status(
await this.put(`/api/v1/users/${user_id}?enabled=${is_enabled}&notes=${notes}`); user_id: string,
is_enabled: boolean,
notes: string,
show_on_leaderboard: boolean
): Promise<void> {
await this.put(
`/api/v1/users/${user_id}?enabled=${is_enabled}&notes=${notes}&show_on_leaderboard=${show_on_leaderboard}`
);
} }
/** /**
+3
View File
@@ -0,0 +1,3 @@
export const ROUTES = {
ADMIN_MESSAGE_DETAIL: (id: string) => `/admin/messages/${id}`,
};
+105 -36
View File
@@ -1,19 +1,39 @@
import { Button, Card, CardBody, Container, FormControl, FormLabel, Input, Stack, useToast } from "@chakra-ui/react"; import {
import { InferGetServerSidePropsType } from "next"; Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Card,
CardBody,
CardHeader,
Checkbox,
CircularProgress,
FormControl,
FormLabel,
Input,
Stack,
useToast,
} from "@chakra-ui/react";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { AdminArea } from "src/components/AdminArea";
import { useCursorPagination } from "src/components/DataTable/useCursorPagination";
import { JsonCard } from "src/components/JsonCard";
import { getAdminLayout } from "src/components/Layout"; import { getAdminLayout } from "src/components/Layout";
import { AdminMessageTable } from "src/components/Messages/AdminMessageTable";
import { Role, RoleSelect } from "src/components/RoleSelect"; import { Role, RoleSelect } from "src/components/RoleSelect";
import { UserMessagesCell } from "src/components/UserMessagesCell"; import { get, post } from "src/lib/api";
import { post } from "src/lib/api";
import { userlessApiClient } from "src/lib/oasst_client_factory"; import { userlessApiClient } from "src/lib/oasst_client_factory";
import prisma from "src/lib/prismadb"; import prisma from "src/lib/prismadb";
import { FetchUserMessagesCursorResponse } from "src/types/Conversation";
import { User } from "src/types/Users";
import useSWRImmutable from "swr/immutable";
import useSWRMutation from "swr/mutation"; import useSWRMutation from "swr/mutation";
interface UserForm { interface UserForm {
user_id: string; user_id: string;
id: string; id: string;
@@ -21,33 +41,17 @@ interface UserForm {
display_name: string; display_name: string;
role: Role; role: Role;
notes: string; notes: string;
show_on_leaderboard: boolean;
} }
const ManageUser = ({ user }: InferGetServerSidePropsType<typeof getServerSideProps>) => { const ManageUser = ({ user }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
const toast = useToast(); const toast = useToast();
const router = useRouter();
const { data: session, status } = useSession();
// Check when the user session is loaded and re-route if the user is not an
// admin. This follows the suggestion by NextJS for handling private pages:
// https://nextjs.org/docs/api-reference/next/router#usage
//
// All admin pages should use the same check and routing steps.
useEffect(() => {
if (status === "loading") {
return;
}
if (session?.user?.role === "admin") {
return;
}
router.push("/");
}, [router, session, status]);
// Trigger to let us update the user's role. Triggers a toast when complete. // Trigger to let us update the user's role. Triggers a toast when complete.
const { trigger } = useSWRMutation("/api/admin/update_user", post, { const { trigger } = useSWRMutation("/api/admin/update_user", post, {
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: "User Role Updated", title: "Updated user",
status: "success", status: "success",
duration: 1000, duration: 1000,
isClosable: true, isClosable: true,
@@ -76,8 +80,8 @@ const ManageUser = ({ user }: InferGetServerSidePropsType<typeof getServerSidePr
content="Conversational AI for everyone. An open source project to create a chat enabled GPT LLM run by LAION and contributors around the world." content="Conversational AI for everyone. An open source project to create a chat enabled GPT LLM run by LAION and contributors around the world."
/> />
</Head> </Head>
<Stack gap="4"> <AdminArea>
<Container> <Stack gap="4">
<Card> <Card>
<CardBody> <CardBody>
<form onSubmit={handleSubmit((data) => trigger(data))}> <form onSubmit={handleSubmit((data) => trigger(data))}>
@@ -88,32 +92,97 @@ const ManageUser = ({ user }: InferGetServerSidePropsType<typeof getServerSidePr
<FormLabel>Display Name</FormLabel> <FormLabel>Display Name</FormLabel>
<Input {...register("display_name")} isDisabled /> <Input {...register("display_name")} isDisabled />
</FormControl> </FormControl>
<FormControl> <FormControl mt="2">
<FormLabel>Role</FormLabel> <FormLabel>Role</FormLabel>
<RoleSelect {...register("role")}></RoleSelect> <RoleSelect {...register("role")}></RoleSelect>
</FormControl> </FormControl>
<FormControl> <FormControl mt="2">
<FormLabel>Notes</FormLabel> <FormLabel>Notes</FormLabel>
<Input {...register("notes")} /> <Input {...register("notes")} />
</FormControl> </FormControl>
<FormControl mt="2">
<FormLabel>Show on leaderboard</FormLabel>
<Checkbox {...register("show_on_leaderboard")}></Checkbox>
</FormControl>
<Button mt={4} type="submit"> <Button mt={4} type="submit">
Update Update
</Button> </Button>
</form> </form>
<Accordion allowToggle mt="4">
<AccordionItem>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Raw JSON
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
<JsonCard>{user}</JsonCard>
</AccordionPanel>
</AccordionItem>
</Accordion>
</CardBody> </CardBody>
</Card> </Card>
</Container> <Card>
<UserMessagesCell path={`/api/admin/user_messages?user=${user.user_id}`} /> <CardHeader pb="0" fontWeight="medium" fontSize="xl">
</Stack> {`User's messages`}
</CardHeader>
<CardBody>
<UserMessageTable id={user.user_id}></UserMessageTable>
</CardBody>
</Card>
</Stack>
</AdminArea>
</> </>
); );
}; };
const UserMessageTable = ({ id }: { id: User["id"] }) => {
const { pagination, toNextPage, toPreviousPage } = useCursorPagination();
const { data, error, isLoading } = useSWRImmutable<FetchUserMessagesCursorResponse>(
`/api/admin/user_messages?user=${id}&cursor=${encodeURIComponent(pagination.cursor)}&direction=${
pagination.direction
}`,
get,
{
keepPreviousData: true,
}
);
if (isLoading && !data) {
return <CircularProgress isIndeterminate />;
}
if (error) {
return <>Unable to load messages.</>;
}
return (
<AdminMessageTable
data={data?.items || []}
disableNext={!data?.next}
disablePrevious={!data?.prev}
onNextClick={() => toNextPage(data)}
onPreviousClick={() => toPreviousPage(data)}
></AdminMessageTable>
);
};
/** /**
* Fetch the user's data on the server side when rendering. * Fetch the user's data on the server side when rendering.
*/ */
export async function getServerSideProps({ query, locale }) { export const getServerSideProps: GetServerSideProps<{ user: User<Role> }, { id: string }> = async ({
const backend_user = await userlessApiClient.fetch_user(query.id); params,
locale = "en",
}) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const backend_user = await userlessApiClient.fetch_user(params!.id as string);
if (!backend_user) {
return {
notFound: true,
};
}
const local_user = await prisma.user.findUnique({ const local_user = await prisma.user.findUnique({
where: { id: backend_user.id }, where: { id: backend_user.id },
select: { select: {
@@ -130,7 +199,7 @@ export async function getServerSideProps({ query, locale }) {
...(await serverSideTranslations(locale, ["common"])), ...(await serverSideTranslations(locale, ["common"])),
}, },
}; };
} };
ManageUser.getLayout = getAdminLayout; ManageUser.getLayout = getAdminLayout;
+2 -2
View File
@@ -6,7 +6,7 @@ import prisma from "src/lib/prismadb";
* Update's the user's data in the database. Accessible only to admins. * Update's the user's data in the database. Accessible only to admins.
*/ */
const handler = withRole("admin", async (req, res, token) => { const handler = withRole("admin", async (req, res, token) => {
const { id, auth_method, user_id, notes, role } = req.body; const { id, auth_method, user_id, notes, role, show_on_leaderboard } = req.body;
const oasstApiClient = await createApiClient(token); const oasstApiClient = await createApiClient(token);
// If the user is authorized by the web, update their role. // If the user is authorized by the web, update their role.
@@ -17,7 +17,7 @@ const handler = withRole("admin", async (req, res, token) => {
}); });
} }
// Tell the backend the user's enabled or not enabled status. // Tell the backend the user's enabled or not enabled status.
await oasstApiClient.set_user_status(user_id, role !== "banned", notes); await oasstApiClient.set_user_status(user_id, role !== "banned", notes, show_on_leaderboard);
res.status(200).json({}); res.status(200).json({});
}); });
+14 -4
View File
@@ -1,15 +1,25 @@
import { withRole } from "src/lib/auth"; import { withRole } from "src/lib/auth";
import { createApiClient } from "src/lib/oasst_client_factory"; import { createApiClient } from "src/lib/oasst_client_factory";
import type { Message } from "src/types/Conversation";
const LIMIT = 10;
/** /**
* Returns the messages recorded by the backend for a user. * Returns the messages recorded by the backend for a user.
*/ */
const handler = withRole("admin", async (req, res, token) => { const handler = withRole("admin", async (req, res, token) => {
const { user } = req.query; const { cursor, direction, user } = req.query;
const oasstApiClient = await createApiClient(token); const oasstApiClient = await createApiClient(token);
const messages: Message[] = await oasstApiClient.fetch_user_messages(user as string);
res.status(200).json(messages); const response = await oasstApiClient.fetch_user_messages_cursor(user as string, {
include_deleted: true,
direction: direction as "back",
cursor: cursor as string,
max_count: LIMIT,
desc: true,
});
res.status(200).json(response);
}); });
export default handler; export default handler;
+7 -3
View File
@@ -3,7 +3,7 @@ import Head from "next/head";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCookies } from "react-cookie"; import { useCookies } from "react-cookie";
import { getDashboardLayout } from "src/components/Layout"; import { getDashboardLayout } from "src/components/Layout";
import { MessageTable } from "src/components/Messages/MessageTable"; import { MessageConversation } from "src/components/Messages/MessageConversation";
import { get } from "src/lib/api"; import { get } from "src/lib/api";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props"; export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props";
@@ -38,7 +38,7 @@ const MessagesDashboard = () => {
borderRadius="xl" borderRadius="xl"
className="p-3 sm:p-4 shadow-sm" className="p-3 sm:p-4 shadow-sm"
> >
{messages ? <MessageTable enableLink messages={messages} /> : <CircularProgress isIndeterminate />} {messages ? <MessageConversation enableLink messages={messages} /> : <CircularProgress isIndeterminate />}
</Box> </Box>
</Box> </Box>
<Box> <Box>
@@ -52,7 +52,11 @@ const MessagesDashboard = () => {
borderRadius="xl" borderRadius="xl"
className="p-6 shadow-sm" className="p-6 shadow-sm"
> >
{userMessages ? <MessageTable enableLink messages={userMessages} /> : <CircularProgress isIndeterminate />} {userMessages ? (
<MessageConversation enableLink messages={userMessages} />
) : (
<CircularProgress isIndeterminate />
)}
</Box> </Box>
</Box> </Box>
</SimpleGrid> </SimpleGrid>
@@ -0,0 +1,20 @@
import { tableAnatomy } from "@chakra-ui/anatomy";
import { createMultiStyleConfigHelpers } from "@chakra-ui/react";
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(tableAnatomy.keys);
export const tableTheme = defineMultiStyleConfig({
variants: {
simple: definePartsStyle(({ colorMode }) => {
const isLightMode = colorMode === "light";
return {
td: {
borderColor: isLightMode ? "gray.100" : "gray.800",
},
th: {
borderColor: isLightMode ? "gray.100" : "gray.800",
},
};
}),
},
});
+2
View File
@@ -5,6 +5,7 @@ import { colors } from "./colors";
import { badgeTheme } from "./components/Badge"; import { badgeTheme } from "./components/Badge";
import { cardTheme } from "./components/Card"; import { cardTheme } from "./components/Card";
import { containerTheme } from "./components/Container"; import { containerTheme } from "./components/Container";
import { tableTheme } from "./components/Table";
const config: ThemeConfig = { const config: ThemeConfig = {
initialColorMode: "light", initialColorMode: "light",
@@ -16,6 +17,7 @@ const components = {
Badge: badgeTheme, Badge: badgeTheme,
Container: containerTheme, Container: containerTheme,
Card: cardTheme, Card: cardTheme,
Table: tableTheme,
}; };
const breakpoints = { const breakpoints = {
+8
View File
@@ -32,3 +32,11 @@ export interface Message extends MessageEmojis {
export interface Conversation { export interface Conversation {
messages: Message[]; messages: Message[];
} }
export type FetchUserMessagesCursorResponse = {
next?: string;
prev?: string;
sort_key: string;
items: Message[];
order: "asc" | "desc";
};
+2 -2
View File
@@ -75,11 +75,11 @@ export interface BackendUser extends BackendUserCore {
/** /**
* An expanded User for the web. * An expanded User for the web.
*/ */
export interface User extends BackendUser { export interface User<TRole extends string = string> extends BackendUser {
/** /**
* The user's roles within the webapp. * The user's roles within the webapp.
*/ */
role: string; role: TRole;
} }
export type FetchUsersParams = { export type FetchUsersParams = {
+7
View File
@@ -1,3 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// https://github.com/ts-essentials/ts-essentials/blob/25cae45c162f8784e3cdae8f43783d0c66370a57/lib/types.ts#L437 // https://github.com/ts-essentials/ts-essentials/blob/25cae45c162f8784e3cdae8f43783d0c66370a57/lib/types.ts#L437
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ElementOf<T extends readonly any[]> = T extends readonly (infer ET)[] ? ET : never; export type ElementOf<T extends readonly any[]> = T extends readonly (infer ET)[] ? ET : never;
type AnyRecord<T = any> = Record<KeyofBase, T>;
type KeyofBase = keyof any;
export type AnyArray<T = any> = Array<T> | ReadonlyArray<T>;
export type StrictOmit<T extends AnyRecord, K extends keyof T> = T extends AnyArray ? never : Omit<T, K>;