mirror of
https://github.com/wassname/Open-Assistant.git
synced 2026-08-17 11:13:32 +08:00
Merge remote-tracking branch 'origin/main' into user_menu_fix
This commit is contained in:
@@ -3,7 +3,6 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Grid,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
Spacer,
|
||||
Tooltip,
|
||||
useBoolean,
|
||||
useColorMode,
|
||||
@@ -23,6 +21,7 @@ import {
|
||||
useId,
|
||||
} from "@chakra-ui/react";
|
||||
import { QuestionMarkCircleIcon } from "@heroicons/react/20/solid";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { FiAlertCircle } from "react-icons/fi";
|
||||
import { get, post } from "src/lib/api";
|
||||
@@ -146,24 +145,25 @@ export const FlaggableElement = (props: FlaggableElementProps) => {
|
||||
isLazy
|
||||
lazyBehavior="keepMounted"
|
||||
>
|
||||
<Grid display="flex" alignItems="center" gap="2">
|
||||
<Box display="flex" alignItems="center" gap="2">
|
||||
<PopoverAnchor>{props.children}</PopoverAnchor>
|
||||
|
||||
<Tooltip label="Report" bg="red.500" aria-label="A tooltip">
|
||||
<div>
|
||||
<Box>
|
||||
<PopoverTrigger>
|
||||
<Box as="button" display="flex" alignItems="center" justifyContent="center" borderRadius="full" p="1">
|
||||
<FiAlertCircle size="20" className="text-red-400" aria-hidden="true" />
|
||||
</Box>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<PopoverContent width="fit-content" p="3">
|
||||
<PopoverContent width="auto" p="3" m="4" maxWidth="calc(100vw - 2rem)">
|
||||
<PopoverArrow />
|
||||
<div className="relative h-4">
|
||||
<Box className="relative h-4">
|
||||
<PopoverCloseButton />
|
||||
</div>
|
||||
</Box>
|
||||
<PopoverBody>
|
||||
{report.label_values.map(({ label, checked, value }, i) => (
|
||||
<FlagCheckbox
|
||||
@@ -207,9 +207,9 @@ export function FlagCheckbox(props: FlagCheckboxProps): JSX.Element {
|
||||
let AdditionalExplanation = null;
|
||||
if (props.label.help_text) {
|
||||
AdditionalExplanation = (
|
||||
<a href="#" className="group flex items-center space-x-2.5 text-sm ">
|
||||
<a href="#" className="text-sm inline group leading-4">
|
||||
<QuestionMarkCircleIcon
|
||||
className="flex h-5 w-5 ml-3 text-gray-400 group-hover:text-gray-500"
|
||||
className="h-5 w-5 ml-1 text-gray-400 group-hover:text-gray-500 inline"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
@@ -221,23 +221,30 @@ export function FlagCheckbox(props: FlagCheckboxProps): JSX.Element {
|
||||
|
||||
const labelTextClass =
|
||||
colorMode === "light"
|
||||
? `text-${colors.light.text} hover:text-blue-700 float-left`
|
||||
: `text-${colors.dark.text} hover:text-blue-400 float-left`;
|
||||
? `text-${colors.light.text} hover:text-blue-700`
|
||||
: `text-${colors.dark.text} hover:text-blue-400`;
|
||||
|
||||
return (
|
||||
<Flex gap="2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
isChecked={props.checked}
|
||||
onChange={(e) => {
|
||||
props.checkboxHandler(e.target.checked, props.idx);
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm form-check-label" htmlFor={id}>
|
||||
<span className={labelTextClass}>{props.label.display_text}</span>
|
||||
{AdditionalExplanation}
|
||||
</label>
|
||||
<Spacer />
|
||||
<Flex gap="4" justifyContent="space-between" className="my-2">
|
||||
<div className="flex items-start align-middle">
|
||||
<Checkbox
|
||||
id={id}
|
||||
isChecked={props.checked}
|
||||
onChange={(e) => {
|
||||
props.checkboxHandler(e.target.checked, props.idx);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className={clsx(
|
||||
"text-sm form-check-label ml-2 break-all inline align-middle first-line:leading-4",
|
||||
labelTextClass
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{props.label.display_text}
|
||||
{AdditionalExplanation}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!props.checked) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export const getDashboardLayout = (page: React.ReactElement) => (
|
||||
>
|
||||
{page}
|
||||
</SideMenuLayout>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function MessageTableEntry(props: MessageTableEntryProps) {
|
||||
|
||||
return (
|
||||
<FlaggableElement message={item}>
|
||||
<HStack w="100%" gap={2}>
|
||||
<HStack w={["full", "full", "full", "fit-content"]} gap={2}>
|
||||
<Box borderRadius="full" border="solid" borderWidth="1px" borderColor={borderColor} bg={avatarColor}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
@@ -28,21 +28,20 @@ export function MessageTableEntry(props: MessageTableEntryProps) {
|
||||
/>
|
||||
</Box>
|
||||
{props.enabled ? (
|
||||
<Box maxWidth="xl">
|
||||
<Box width={["full", "full", "full", "fit-content"]} maxWidth={["full", "full", "full", "2xl"]}>
|
||||
<Link href={`/messages/${item.id}`}>
|
||||
<LinkBox
|
||||
bg={item.is_assistant ? backgroundColor : backgroundColor2}
|
||||
className={`p-4 rounded-md whitespace-pre-wrap w-full`}
|
||||
>
|
||||
<LinkBox bg={item.is_assistant ? backgroundColor : backgroundColor2} p="4" borderRadius="md">
|
||||
{item.text}
|
||||
</LinkBox>
|
||||
</Link>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
maxWidth="xl"
|
||||
width={["full", "full", "full", "fit-content"]}
|
||||
maxWidth={["full", "full", "full", "2xl"]}
|
||||
bg={item.is_assistant ? backgroundColor : backgroundColor2}
|
||||
className={`p-4 rounded-md whitespace-pre-wrap w-full`}
|
||||
p="4"
|
||||
borderRadius="md"
|
||||
>
|
||||
{item.text}
|
||||
</Box>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -22,7 +23,7 @@ import useSWR from "swr";
|
||||
*/
|
||||
const UsersCell = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
// Fetch and save the users.
|
||||
// This follows useSWR's recommendation for simple pagination:
|
||||
@@ -53,21 +54,23 @@ const UsersCell = () => {
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Id</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Auth Id</Th>
|
||||
<Th>Auth Method</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Update</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.map((user, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>{user.id}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.role}</Td>
|
||||
{users.map(({ id, user_id, auth_method, display_name, role }) => (
|
||||
<Tr key={user_id}>
|
||||
<Td>{user_id}</Td>
|
||||
<Td>{id}</Td>
|
||||
<Td>{auth_method}</Td>
|
||||
<Td>{display_name}</Td>
|
||||
<Td>{role}</Td>
|
||||
<Td>
|
||||
<Link href={`/admin/manage_user/${user.id}`}>Manage</Link>
|
||||
<Link href={`/admin/manage_user/${user_id}`}>Manage</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { JWT } from "next-auth/jwt";
|
||||
import type { Message } from "src/types/Conversation";
|
||||
import type { BackendUser } from "src/types/Users";
|
||||
|
||||
export class OasstError {
|
||||
message: string;
|
||||
@@ -43,6 +45,32 @@ export class OasstApiClient {
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
private async put(path: string): Promise<any> {
|
||||
const resp = await fetch(`${this.oasstApiUrl}${path}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-API-Key": this.oasstApiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resp.status >= 300) {
|
||||
const errorText = await resp.text();
|
||||
let error: any;
|
||||
try {
|
||||
error = JSON.parse(errorText);
|
||||
} catch (e) {
|
||||
throw new OasstError(errorText, 0, resp.status);
|
||||
}
|
||||
throw new OasstError(error.message ?? error, error.error_code, resp.status);
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<any> {
|
||||
const resp = await fetch(`${this.oasstApiUrl}${path}`, {
|
||||
method: "GET",
|
||||
@@ -121,6 +149,34 @@ export class OasstApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `BackendUser` associated with `user_id`
|
||||
*/
|
||||
async fetch_user(user_id: string): Promise<BackendUser> {
|
||||
return this.get(`/api/v1/users/users/${user_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `max_count` `BackendUser`s stored by the backend.
|
||||
*/
|
||||
async fetch_users(max_count: number): Promise<BackendUser[]> {
|
||||
return this.get(`/api/v1/frontend_users/?max_count=${max_count}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `Message`s associated with `user_id` in the backend.
|
||||
*/
|
||||
async fetch_user_messages(user_id: string): Promise<Message[]> {
|
||||
return this.get(`/api/v1/users/${user_id}/messages`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the backend's knowledge about the `user_id`.
|
||||
*/
|
||||
async set_user_status(user_id: string, is_enabled: boolean, notes): Promise<void> {
|
||||
return this.put(`/api/v1/users/users/${user_id}?enabled=${is_enabled}¬es=${notes}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the valid labels for messages.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect } from "react";
|
||||
import { getAdminLayout } from "src/components/Layout";
|
||||
import { UserMessagesCell } from "src/components/UserMessagesCell";
|
||||
import { post } from "src/lib/api";
|
||||
import { oasstApiClient } from "src/lib/oasst_api_client";
|
||||
import prisma from "src/lib/prismadb";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
|
||||
@@ -68,24 +69,17 @@ const ManageUser = ({ user }) => {
|
||||
}}
|
||||
>
|
||||
<Form>
|
||||
<Field name="user_id" type="hidden" />
|
||||
<Field name="id" type="hidden" />
|
||||
<Field name="name">
|
||||
<Field name="auth_method" type="hidden" />
|
||||
<Field name="display_name">
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>Display Name</FormLabel>
|
||||
<Input {...field} isDisabled />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="email">
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<Input {...field} isDisabled />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="role">
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
@@ -98,13 +92,21 @@ const ManageUser = ({ user }) => {
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="notes">
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Button mt={4} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Container>
|
||||
<UserMessagesCell path={`/api/admin/user_messages?user=${user.id}`} />
|
||||
<UserMessagesCell path={`/api/admin/user_messages?user=${user.user_id}`} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
@@ -114,15 +116,17 @@ const ManageUser = ({ user }) => {
|
||||
* Fetch the user's data on the server side when rendering.
|
||||
*/
|
||||
export async function getServerSideProps({ query }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: query.id },
|
||||
const backend_user = await oasstApiClient.fetch_user(query.id);
|
||||
const local_user = await prisma.user.findUnique({
|
||||
where: { id: backend_user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
const user = {
|
||||
...backend_user,
|
||||
role: local_user?.role || "general",
|
||||
};
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { withRole } from "src/lib/auth";
|
||||
import { oasstApiClient } from "src/lib/oasst_api_client";
|
||||
import prisma from "src/lib/prismadb";
|
||||
|
||||
/**
|
||||
* Update's the user's data in the database. Accessible only to admins.
|
||||
*/
|
||||
const handler = withRole("admin", async (req, res) => {
|
||||
const { id, role } = req.body;
|
||||
const { id, auth_method, user_id, notes, role } = req.body;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
// If the user is authorized by the web, update their role.
|
||||
if (auth_method === "local") {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Tell the backend the user's enabled or not enabled status.
|
||||
await oasstApiClient.set_user_status(user_id, role !== "banned", notes);
|
||||
|
||||
res.status(200).end();
|
||||
res.status(200).json({});
|
||||
});
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { withRole } from "src/lib/auth";
|
||||
import { oasstApiClient } from "src/lib/oasst_api_client";
|
||||
import type { Message } from "src/types/Conversation";
|
||||
|
||||
/**
|
||||
* Returns the messages recorded by the backend for a user.
|
||||
*/
|
||||
const handler = withRole("admin", async (req, res) => {
|
||||
const { user } = req.query;
|
||||
|
||||
const messagesRes = await fetch(`${process.env.FASTAPI_URL}/api/v1/frontend_users/local/${user}/messages`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-Key": process.env.FASTAPI_KEY,
|
||||
},
|
||||
});
|
||||
const messages = await messagesRes.json();
|
||||
const messages: Message[] = await oasstApiClient.fetch_user_messages(user as string);
|
||||
res.status(200).json(messages);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { withRole } from "src/lib/auth";
|
||||
import { oasstApiClient } from "src/lib/oasst_api_client";
|
||||
import prisma from "src/lib/prismadb";
|
||||
|
||||
// The number of users to fetch in any request.
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Returns a list of user results from the database when the requesting user is
|
||||
* a logged in admin.
|
||||
*/
|
||||
const handler = withRole("admin", async (req, res) => {
|
||||
// Figure out the pagination index and skip that number of users.
|
||||
//
|
||||
// Note: with Prisma this isn't the most efficient but it's the only possible
|
||||
// option with cuid based User IDs.
|
||||
const { pageIndex } = req.query;
|
||||
const skip = parseInt(pageIndex as string) * PAGE_SIZE || 0;
|
||||
// TODO(#673): Update this to support pagination.
|
||||
|
||||
// Fetch 20 users.
|
||||
const users = await prisma.user.findMany({
|
||||
// First, get all the users according to the backend.
|
||||
const all_users = await oasstApiClient.fetch_users(20);
|
||||
|
||||
// Next, get all the users stored in the web's auth datbase to fetch their role.
|
||||
const local_user_ids = all_users.map(({ id }) => id);
|
||||
const local_users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: local_user_ids,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
skip,
|
||||
take: PAGE_SIZE,
|
||||
});
|
||||
|
||||
// Combine the information by updating the set of full users with their role.
|
||||
// Default any users without a role set locally as "general".
|
||||
const local_user_map = local_users.reduce((result, user) => {
|
||||
result.set(user.id, user.role);
|
||||
return result;
|
||||
}, new Map());
|
||||
|
||||
const users = all_users.map((user) => {
|
||||
const role = local_user_map.get(user.id) || "general";
|
||||
return {
|
||||
...user,
|
||||
role,
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json(users);
|
||||
|
||||
@@ -50,7 +50,7 @@ if (boolean(process.env.DEBUG_LOGIN) || process.env.NODE_ENV === "development")
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
update: {},
|
||||
update: user,
|
||||
create: user,
|
||||
});
|
||||
return user;
|
||||
@@ -86,6 +86,7 @@ export const authOptions: AuthOptions = {
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
session.user.role = token.role;
|
||||
session.user.isNew = token.isNew;
|
||||
return session;
|
||||
},
|
||||
/**
|
||||
@@ -93,11 +94,12 @@ export const authOptions: AuthOptions = {
|
||||
* This let's use forward the role to the session object.
|
||||
*/
|
||||
async jwt({ token }) {
|
||||
const { role } = await prisma.user.findUnique({
|
||||
const { isNew, role } = await prisma.user.findUnique({
|
||||
where: { id: token.sub },
|
||||
select: { role: true },
|
||||
select: { role: true, isNew: true },
|
||||
});
|
||||
token.role = role;
|
||||
token.isNew = isNew;
|
||||
return token;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,6 +17,9 @@ const handler = withoutRole("banned", async (req, res, token) => {
|
||||
// Parse out the local task ID and the interaction contents.
|
||||
const { id: frontendId, content, update_type } = req.body;
|
||||
|
||||
// Record that the user has done meaningful work and is no longer new.
|
||||
await prisma.user.update({ where: { id: token.sub }, data: { isNew: false } });
|
||||
|
||||
// Accept the task so that we can complete it, this will probably go away soon.
|
||||
const registeredTask = await prisma.registeredTask.findUniqueOrThrow({ where: { id: frontendId } });
|
||||
const task = registeredTask.task as Prisma.JsonObject;
|
||||
|
||||
@@ -94,7 +94,14 @@ function Signin({ csrfToken, providers }) {
|
||||
{email && (
|
||||
<form onSubmit={signinWithEmail}>
|
||||
<Stack>
|
||||
<Input data-cy="email-address" variant="outline" size="lg" placeholder="Email Address" ref={emailEl} />
|
||||
<Input
|
||||
type="email"
|
||||
data-cy="email-address"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
placeholder="Email Address"
|
||||
ref={emailEl}
|
||||
/>
|
||||
<Button
|
||||
data-cy="signin-email-button"
|
||||
size={"lg"}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import Head from "next/head";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { LeaderboardTable, TaskOption } from "src/components/Dashboard";
|
||||
import { getDashboardLayout } from "src/components/Layout";
|
||||
import { TaskCategory } from "src/components/Tasks/TaskTypes";
|
||||
|
||||
const Dashboard = () => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// TODO(#670): Do something more meaningful when the user is new.
|
||||
console.log(session?.user?.isNew);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
||||
@@ -27,6 +27,6 @@ const RandomTask = () => {
|
||||
);
|
||||
};
|
||||
|
||||
RandomTask.getLayout = getDashboardLayout;
|
||||
RandomTask.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default RandomTask;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Reports the Backend's knowledge of a user.
|
||||
*/
|
||||
export interface BackendUser {
|
||||
/**
|
||||
* The user's unique ID according to the `auth_method`.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The user's set name
|
||||
*/
|
||||
display_name: string;
|
||||
|
||||
/**
|
||||
* The authorization method. One of:
|
||||
* - discord
|
||||
* - local
|
||||
*/
|
||||
auth_method: string;
|
||||
|
||||
/**
|
||||
* The backend's UUID for this user.
|
||||
*/
|
||||
user_id: string;
|
||||
|
||||
/**
|
||||
* Arbitrary notes about the user.
|
||||
*/
|
||||
notes: string;
|
||||
|
||||
/**
|
||||
* True when the user is able to access the platform. False otherwise.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* True when the user is marked for deletion. False otherwise.
|
||||
*/
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An expanded User for the web.
|
||||
*/
|
||||
export interface User extends BackendUser {
|
||||
/**
|
||||
* The user's roles within the webapp.
|
||||
*/
|
||||
role: string;
|
||||
}
|
||||
Reference in New Issue
Block a user