forked from administration/panel
feat: full report management
parent
006acdb6ac
commit
45bce9d5fb
|
@ -28,9 +28,11 @@ export default async function Message({
|
|||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{author && (
|
||||
<Link href={`/panel/inspect/user/${author!._id}`}>
|
||||
<UserCard user={author!} subtitle="Message Author" />
|
||||
</Link>
|
||||
)}
|
||||
<Link href={`/panel/inspect/channel/${channel!._id}`}>
|
||||
<ChannelCard channel={channel!} subtitle="Channel" />
|
||||
</Link>
|
||||
|
|
|
@ -41,8 +41,18 @@ export default async function Reports({ params }: { params: { id: string } }) {
|
|||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<NavigationToolbar>Viewing Report</NavigationToolbar>
|
||||
<ReportCard report={report} />
|
||||
<ReportActions report={report} />
|
||||
<ReportActions
|
||||
report={report}
|
||||
reference={`${report._id.substring(20, 26)}, ${
|
||||
snapshots[0]._type === "Message"
|
||||
? "Message"
|
||||
: snapshots[0]._type === "User"
|
||||
? `${snapshots[0].username}#${snapshots[0].discriminator}`
|
||||
: snapshots[0].name
|
||||
}, ${report.content.report_reason}${
|
||||
report.additional_context ? `, ${report.additional_context}` : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
<Link href={`/panel/inspect/user/${author!._id}`}>
|
||||
<UserCard user={author!} subtitle="Report Author" />
|
||||
|
|
|
@ -68,9 +68,9 @@ export function CompactMessage({
|
|||
</div>
|
||||
)}
|
||||
<div className="flex-[2] min-w-0 overflow-ellipsis overflow-hidden text-left">
|
||||
{(message.attachments || message.embeds) && (
|
||||
{message.attachments?.length || message.embeds?.length ? (
|
||||
<ImageIcon size={12} className="inline align-middle" />
|
||||
)}{" "}
|
||||
) : null}{" "}
|
||||
{message.content ?? <span className="text-gray-500">No text.</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -80,9 +80,7 @@ export function CompactMessage({
|
|||
<AlertDialogTitle>
|
||||
{user?.avatar && (
|
||||
<Avatar className="inline-block align-middle mr-1">
|
||||
<AvatarImage
|
||||
src={`${AUTUMN_URL}/avatars/${user.avatar._id}`}
|
||||
/>
|
||||
<AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar._id}`} />
|
||||
</Avatar>
|
||||
)}{" "}
|
||||
{user?.username}#{user?.discriminator}
|
||||
|
|
|
@ -16,17 +16,28 @@ export function ReportCard({ report }: { report: Report }) {
|
|||
report.status !== "Created" ? "text-gray-500" : ""
|
||||
}`}
|
||||
>
|
||||
{report.content.report_reason.includes("Illegal") && (
|
||||
{report.status === "Resolved" ? (
|
||||
<Badge className="align-middle">Resolved</Badge>
|
||||
) : report.status === "Rejected" ? (
|
||||
<Badge className="align-middle">
|
||||
Closed for {report.rejection_reason}
|
||||
</Badge>
|
||||
) : (
|
||||
report.content.report_reason.includes("Illegal") && (
|
||||
<Badge className="align-middle" variant="destructive">
|
||||
Urgent
|
||||
</Badge>
|
||||
)
|
||||
)}{" "}
|
||||
{report.additional_context || "No reason specified"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{report._id.toString().substring(20, 26)} ·{" "}
|
||||
{report.content.report_reason} · {report.content.type} ·{" "}
|
||||
{dayjs(decodeTime(report._id)).fromNow()}
|
||||
{dayjs(decodeTime(report._id)).fromNow()}{" "}
|
||||
{report.status !== "Created" && report.closed_at && (
|
||||
<>· Closed {dayjs(report.closed_at).fromNow()}</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
|
|
@ -17,7 +17,7 @@ export async function RecentMessages({
|
|||
query: Filter<Message>;
|
||||
users?: boolean | User[];
|
||||
}) {
|
||||
const recentMessages = await fetchMessages(query);
|
||||
const recentMessages = (await fetchMessages(query)).reverse();
|
||||
|
||||
const userList = (
|
||||
users === true
|
||||
|
|
|
@ -68,7 +68,9 @@ export function RelevantModerationNotices({
|
|||
{strikesDraft.map((strike) => (
|
||||
<TableRow key={strike._id}>
|
||||
<TableCell>{strike.reason}</TableCell>
|
||||
<TableCell>{dayjs(decodeTime(strike._id)).fromNow()}</TableCell>
|
||||
<TableCell>
|
||||
{dayjs(decodeTime(strike._id))?.fromNow()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
|
|
@ -11,16 +11,77 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "../ui/use-toast";
|
||||
import { updateReportNotes } from "@/lib/actions";
|
||||
import {
|
||||
rejectReport,
|
||||
reopenReport,
|
||||
resolveReport,
|
||||
sendAlert,
|
||||
updateReportNotes,
|
||||
} from "@/lib/actions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "../ui/alert-dialog";
|
||||
import { ReportCard } from "../cards/ReportCard";
|
||||
|
||||
export function ReportActions({ report }: { report: Report }) {
|
||||
const template: Record<string, (ref: string) => string> = {
|
||||
resolved: (ref) =>
|
||||
`Your report (${ref}) has been actioned and appropriate action has been taken.`,
|
||||
invalid: (ref) => `Your report (${ref}) has been marked as invalid.`,
|
||||
false: (ref) =>
|
||||
`Your report (${ref}) has been marked as false or spam. False reports may lead to additional action against your account.`,
|
||||
duplicate: (ref) => `Your report (${ref}) has been marked as a duplicate.`,
|
||||
"not enough evidence": (ref) =>
|
||||
`Your report (${ref}) has not been actioned at this time due to a lack of supporting evidence, if you have additional information to support your report, please either report individual relevant messages or send an email to contact@revolt.chat.`,
|
||||
clarify: (ref) =>
|
||||
`Your report (${ref}) needs clarification, please provide additional information.`,
|
||||
acknowledged: (ref) =>
|
||||
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
|
||||
default: (ref) =>
|
||||
`Report (${ref})\n\nNo template found for rejection reason, please specify.`,
|
||||
};
|
||||
|
||||
export function ReportActions({
|
||||
report,
|
||||
reference,
|
||||
}: {
|
||||
report: Report;
|
||||
reference: string;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [reportDraft, setDraft] = useState(report);
|
||||
|
||||
function rejectHandler(reason: string) {
|
||||
return async () => {
|
||||
try {
|
||||
const $set = await rejectReport(report._id, reason);
|
||||
setDraft((report) => ({ ...report, ...$set }));
|
||||
toast({
|
||||
title: "Rejected report",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to reject report",
|
||||
description: String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportCard report={reportDraft} />
|
||||
|
||||
<Textarea
|
||||
placeholder="Enter notes here... (save on unfocus)"
|
||||
className="!min-h-0 !h-[76px]"
|
||||
|
@ -46,9 +107,26 @@ export function ReportActions({ report }: { report: Report }) {
|
|||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{report.status === "Created" ? (
|
||||
{reportDraft.status === "Created" ? (
|
||||
<>
|
||||
<Button className="flex-1 bg-green-400 hover:bg-green-300">
|
||||
<Button
|
||||
className="flex-1 bg-green-400 hover:bg-green-300"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const $set = await resolveReport(report._id);
|
||||
setDraft((report) => ({ ...report, ...$set }));
|
||||
toast({
|
||||
title: "Resolved report",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to resolve report",
|
||||
description: String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
|
@ -58,23 +136,117 @@ export function ReportActions({ report }: { report: Report }) {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Custom Reason</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const reason = prompt("Enter a custom reason:");
|
||||
// TODO: modal
|
||||
reason && rejectHandler(reason)();
|
||||
}}
|
||||
>
|
||||
Custom Reason
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Presets</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Invalid</DropdownMenuItem>
|
||||
<DropdownMenuItem>False or Spam</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuItem>Not Enough Evidence</DropdownMenuItem>
|
||||
<DropdownMenuItem>Request Clarification</DropdownMenuItem>
|
||||
<DropdownMenuItem>Acknowledge Only</DropdownMenuItem>
|
||||
<DropdownMenuItem>Ignore</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={rejectHandler("invalid")}>
|
||||
Invalid
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={rejectHandler("false")}>
|
||||
False or Spam
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={rejectHandler("duplicate")}>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={rejectHandler("not enough evidence")}
|
||||
>
|
||||
Not Enough Evidence
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={rejectHandler("clarify")}>
|
||||
Request Clarification
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={rejectHandler("acknowledged")}>
|
||||
Acknowledge Only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={rejectHandler("ignore")}>
|
||||
Ignore
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="flex-1">Re-open</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const $set = await reopenReport(report._id);
|
||||
setDraft((report) => ({ ...report, ...$set }));
|
||||
toast({
|
||||
title: "Opened report again",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to re-open report",
|
||||
description: String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Re-open
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="flex-1">Send resolution notification</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send Notification</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
This will send a message from the Platform Moderation
|
||||
account.
|
||||
</span>
|
||||
<Textarea
|
||||
id="notification-content"
|
||||
defaultValue={(
|
||||
template[
|
||||
reportDraft.status === "Rejected"
|
||||
? reportDraft.rejection_reason
|
||||
: "resolved"
|
||||
] ?? template["default"]
|
||||
)(reference)}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
const msg = (
|
||||
document.getElementById(
|
||||
"notification-content"
|
||||
) as HTMLTextAreaElement
|
||||
).value;
|
||||
if (!msg) return;
|
||||
|
||||
sendAlert(report.author_id, msg)
|
||||
.then(() => toast({ title: "Sent Alert" }))
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: "Failed to send alert!",
|
||||
description: String(err),
|
||||
variant: "destructive",
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { Input } from "../ui/input";
|
||||
import {
|
||||
banUser,
|
||||
closeReportsByUser,
|
||||
sendAlert,
|
||||
suspendUser,
|
||||
unsuspendUser,
|
||||
|
@ -249,17 +250,10 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
More Options
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent className="flex flex-col">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
throw "Cancel immediate propagation.";
|
||||
}}
|
||||
disabled={userInaccessible}
|
||||
>
|
||||
Send Alert
|
||||
</DropdownMenuItem>
|
||||
<Button variant="ghost">Send Alert</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
@ -302,6 +296,42 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost">Close Open Reports</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Close Open Reports</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
This will close all reports still open by this user.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
closeReportsByUser(user._id)
|
||||
.then((reports) =>
|
||||
toast({ title: `Closed ${reports} Reports` })
|
||||
)
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: "Failed to close reports!",
|
||||
description: String(err),
|
||||
variant: "destructive",
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* <DropdownMenuItem>
|
||||
Clear ({counts.pending}) Friend Requests
|
||||
</DropdownMenuItem>
|
||||
|
|
|
@ -85,6 +85,80 @@ export async function updateReportNotes(reportId: string, notes: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function resolveReport(reportId: string) {
|
||||
const $set = {
|
||||
status: "Resolved",
|
||||
closed_at: new Date().toISOString(),
|
||||
} as Report;
|
||||
|
||||
await mongo().db("revolt").collection<Report>("safety_reports").updateOne(
|
||||
{ _id: reportId },
|
||||
{
|
||||
$set,
|
||||
}
|
||||
);
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
export async function rejectReport(reportId: string, reason: string) {
|
||||
const $set = {
|
||||
status: "Rejected",
|
||||
rejection_reason: reason,
|
||||
closed_at: new Date().toISOString(),
|
||||
} as Report;
|
||||
|
||||
await mongo().db("revolt").collection<Report>("safety_reports").updateOne(
|
||||
{ _id: reportId },
|
||||
{
|
||||
$set,
|
||||
}
|
||||
);
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
export async function reopenReport(reportId: string) {
|
||||
const $set = {
|
||||
status: "Created",
|
||||
} as Report;
|
||||
|
||||
await mongo()
|
||||
.db("revolt")
|
||||
.collection<Report>("safety_reports")
|
||||
.updateOne(
|
||||
{ _id: reportId },
|
||||
{
|
||||
$set,
|
||||
$unset: {
|
||||
closed_at: 1,
|
||||
} as never,
|
||||
}
|
||||
);
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
export async function closeReportsByUser(userId: string) {
|
||||
return await mongo()
|
||||
.db("revolt")
|
||||
.collection<Report>("safety_reports")
|
||||
.updateMany(
|
||||
{
|
||||
status: "Created",
|
||||
author_id: userId,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: "Rejected",
|
||||
rejection_reason: "bulk close",
|
||||
closed_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((res) => res.modifiedCount);
|
||||
}
|
||||
|
||||
export async function disableAccount(userId: string) {
|
||||
await mongo()
|
||||
.db("revolt")
|
||||
|
|
Loading…
Reference in New Issue