359 lines
9.3 KiB
TypeScript
359 lines
9.3 KiB
TypeScript
import {
|
|
Alert,
|
|
AlertIcon,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
CardBody,
|
|
CardHeader,
|
|
Container,
|
|
Flex,
|
|
Heading,
|
|
HStack,
|
|
Image,
|
|
Input,
|
|
InputGroup,
|
|
InputRightElement,
|
|
Link,
|
|
Modal,
|
|
ModalBody,
|
|
ModalCloseButton,
|
|
ModalContent,
|
|
ModalFooter,
|
|
ModalHeader,
|
|
ModalOverlay,
|
|
Spacer,
|
|
Stack,
|
|
StackDivider,
|
|
Text,
|
|
useDisclosure,
|
|
useToast,
|
|
} from "@chakra-ui/react";
|
|
import { type FormEvent, type ReactElement, useState } from "react";
|
|
|
|
export async function loader({ context }: { context: RequestContext }) {
|
|
const { current_user: currentUser } = context.data;
|
|
|
|
if (!currentUser)
|
|
throw new Response(null, {
|
|
status: 401,
|
|
});
|
|
|
|
if (
|
|
!(currentUser.permissions & (1 << 5)) &&
|
|
!(currentUser.permissions & (1 << 8))
|
|
)
|
|
throw new Response(null, {
|
|
status: 403,
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
export function meta() {
|
|
return [{ title: "Hammer - Car Crushers" }];
|
|
}
|
|
|
|
export default function () {
|
|
const [queriedUsername, setQueriedUsername] = useState("");
|
|
const [username, setUsername] = useState("");
|
|
const [uid, setUid] = useState("");
|
|
const [status, setStatus] = useState("");
|
|
const [visible, setVisible] = useState(false);
|
|
const [avatarUrl, setAvatarUrl] = useState("");
|
|
const [ticketLink, setTicketLink] = useState("");
|
|
const [history, setHistory] = useState([] as ReactElement[]);
|
|
const [hasResults, setHasResults] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
|
const toast = useToast();
|
|
const ticketRegex = /https:\/\/carcrushers\.modmail\.dev\/logs\/[a-f\d]{12}$/;
|
|
|
|
async function getHistory() {
|
|
setVisible(false);
|
|
setLoading(true);
|
|
setHistory([]);
|
|
|
|
if (queriedUsername.length < 4) {
|
|
setLoading(false);
|
|
return toast({
|
|
title: "Validation Error",
|
|
description: `Username is too short`,
|
|
status: "error",
|
|
});
|
|
}
|
|
|
|
const historyResp = await fetch(
|
|
`/api/game-bans/${queriedUsername}/history`,
|
|
);
|
|
|
|
if (!historyResp.ok) {
|
|
setLoading(false);
|
|
return toast({
|
|
title: "Failed To Fetch User",
|
|
description: `${
|
|
((await historyResp.json()) as { error: string }).error
|
|
}`,
|
|
status: "error",
|
|
});
|
|
}
|
|
|
|
const {
|
|
history,
|
|
user,
|
|
}: {
|
|
history: { [k: string]: any }[];
|
|
user: {
|
|
avatar: string | null;
|
|
current_status: string;
|
|
id: number;
|
|
name: string;
|
|
};
|
|
} = await historyResp.json();
|
|
|
|
if (!history.length) {
|
|
setLoading(false);
|
|
setHasResults(false);
|
|
setStatus("");
|
|
return toast({
|
|
title: "Nothing Found",
|
|
description: "This user doesn't have any moderation history.",
|
|
status: "info",
|
|
});
|
|
}
|
|
|
|
setHasResults(true);
|
|
const cardList = [];
|
|
|
|
setAvatarUrl(user.avatar ?? "https://i.hep.gg/floppa");
|
|
setUid(user.id.toString());
|
|
setUsername(user.name);
|
|
setStatus(user.current_status);
|
|
|
|
for (const entry of history) {
|
|
const url = entry.entity.properties.evidence.stringValue;
|
|
const isUrl = () => {
|
|
try {
|
|
new URL(url).href;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
cardList.push(
|
|
<Container mb={3}>
|
|
<Card>
|
|
<CardHeader>
|
|
<Heading size="md">
|
|
{new Date(
|
|
parseInt(entry.entity.properties.executed_at.integerValue),
|
|
).toLocaleString()}
|
|
</Heading>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<Stack divider={<StackDivider />} spacing="4">
|
|
<Box>
|
|
<Heading size="xs">ACTION</Heading>
|
|
<Text pt="2" size="sm">
|
|
{entry.entity.properties.action.stringValue}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Heading size="xs">EVIDENCE</Heading>
|
|
<Text pt="2" size="sm">
|
|
{isUrl() ? (
|
|
<Link color="#646cff" href={url}>
|
|
{url}
|
|
</Link>
|
|
) : (
|
|
url
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
</Stack>
|
|
</CardBody>
|
|
</Card>
|
|
</Container>,
|
|
);
|
|
}
|
|
|
|
setHistory(cardList);
|
|
setLoading(false);
|
|
setVisible(true);
|
|
}
|
|
|
|
const invalidIcon = (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
fill="currentColor"
|
|
viewBox="0 0 16 16"
|
|
color="red"
|
|
>
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
|
</svg>
|
|
);
|
|
|
|
const validIcon = (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
fill="currentColor"
|
|
viewBox="0 0 16 16"
|
|
color="green"
|
|
>
|
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z" />
|
|
</svg>
|
|
);
|
|
|
|
async function revokePunishment() {
|
|
const revokeResponse = await fetch(`/api/game-bans/${uid}/revoke`, {
|
|
body: JSON.stringify({ ticket_link: ticketLink }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
if (!revokeResponse.ok) {
|
|
let error: string;
|
|
|
|
try {
|
|
error = ((await revokeResponse.json()) as { error: string }).error;
|
|
} catch {
|
|
error = "Unknown error";
|
|
}
|
|
|
|
toast({
|
|
description: error,
|
|
isClosable: true,
|
|
status: "error",
|
|
title: "Oops",
|
|
});
|
|
} else {
|
|
toast({
|
|
description: `Punishment revoked for ${username}`,
|
|
isClosable: true,
|
|
status: "success",
|
|
title: "Success",
|
|
});
|
|
}
|
|
|
|
onClose();
|
|
setTicketLink("");
|
|
}
|
|
|
|
return (
|
|
<Container maxW="container.md">
|
|
<Modal isCentered isOpen={isOpen} onClose={onClose} size="lg">
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader>Revoke punishment for {username}</ModalHeader>
|
|
<ModalCloseButton />
|
|
<ModalBody>
|
|
<InputGroup>
|
|
<Input
|
|
onChange={(e) => setTicketLink(e.target.value)}
|
|
placeholder="https://carcrushers.modmail.dev/logs/abcdef123456"
|
|
maxLength={49}
|
|
/>
|
|
<InputRightElement>
|
|
{ticketLink.match(ticketRegex) ? validIcon : invalidIcon}
|
|
</InputRightElement>
|
|
</InputGroup>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button
|
|
onClick={() => {
|
|
onClose();
|
|
setTicketLink("");
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
colorScheme="red"
|
|
disabled={!Boolean(ticketLink.match(ticketRegex))}
|
|
ml="8px"
|
|
onClick={async () => await revokePunishment()}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Heading>User Lookup</Heading>
|
|
<Text>Look up a user's punishment history here.</Text>
|
|
{!hasResults ? (
|
|
<Alert status="warning" mt={2}>
|
|
<AlertIcon />
|
|
No information was found for this user. Perhaps you misspelt their
|
|
name?
|
|
</Alert>
|
|
) : null}
|
|
<HStack mt={5}>
|
|
<Input
|
|
id="username"
|
|
onBeforeInput={(e) => {
|
|
const { data }: { data?: string } & FormEvent<HTMLInputElement> = e;
|
|
|
|
if (data?.match(/\W/)) e.preventDefault();
|
|
}}
|
|
onChange={(e) => setQueriedUsername(e.target.value)}
|
|
placeholder="Roblox username"
|
|
/>
|
|
<Button
|
|
ml="8px"
|
|
onClick={async () => await getHistory()}
|
|
isLoading={loading}
|
|
>
|
|
Search
|
|
</Button>
|
|
</HStack>
|
|
<Container mb={3} mt={3}>
|
|
<Card visibility={visible ? "visible" : "hidden"}>
|
|
<CardBody>
|
|
<Flex flexWrap="wrap" justifyContent="center">
|
|
<Image mb="16" src={avatarUrl} />
|
|
<Spacer />
|
|
<Button
|
|
colorScheme="red"
|
|
onClick={onOpen}
|
|
visibility={
|
|
status !== "Not Moderated" && !status ? "visible" : "hidden"
|
|
}
|
|
>
|
|
Revoke Punishment
|
|
</Button>
|
|
</Flex>
|
|
<Stack divider={<StackDivider />} mt="8px" spacing="6">
|
|
<Box>
|
|
<Heading size="xs">USERNAME</Heading>
|
|
<Text pt="2" fontSize="sm">
|
|
{username}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Heading size="xs">USER ID</Heading>
|
|
<Text pt="2" fontSize="sm">
|
|
{uid}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Heading size="xs">MODERATION STATUS</Heading>
|
|
<Text pt="2" fontSize="sm">
|
|
{status}
|
|
</Text>
|
|
</Box>
|
|
</Stack>
|
|
</CardBody>
|
|
</Card>
|
|
</Container>
|
|
{history}
|
|
</Container>
|
|
);
|
|
}
|