forked from administration/panel
Compare commits
12 Commits
feat/audit
...
main
Author | SHA1 | Date |
---|---|---|
|
73ca6ec732 | |
|
e498e9d261 | |
|
78ce9f96d7 | |
|
96d5884461 | |
|
b79a14f00c | |
|
7f1619d33a | |
|
5cdb02b5bf | |
|
500f8b3e1c | |
|
c30d75c0ed | |
|
8113a86db9 | |
|
eb0bd7a7c9 | |
|
e9b623f60d |
|
@ -0,0 +1,41 @@
|
||||||
|
import { ReportCard } from "@/components/cards/ReportCard";
|
||||||
|
import { CardLink } from "@/components/common/CardLink";
|
||||||
|
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||||
|
import { CaseActions } from "@/components/pages/inspector/CaseActions";
|
||||||
|
import { fetchCaseById, fetchReportsByCase } from "@/lib/db";
|
||||||
|
import { PizzaIcon } from "lucide-react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function Reports({ params }: { params: { id: string } }) {
|
||||||
|
const Case = await fetchCaseById(params.id);
|
||||||
|
if (!Case) return notFound();
|
||||||
|
|
||||||
|
const reports = await fetchReportsByCase(params.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<NavigationToolbar>Viewing Case</NavigationToolbar>
|
||||||
|
<CaseActions Case={Case} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-2xl">Reports</h1>
|
||||||
|
{reports.length ? (
|
||||||
|
reports.map((report) => (
|
||||||
|
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
||||||
|
<ReportCard report={report} />
|
||||||
|
</CardLink>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-8 flex justify-center">
|
||||||
|
<PizzaIcon className="text-gray-400" />
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-xs text-center pb-2 text-gray-400">
|
||||||
|
No reports added yet.
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { CaseCard } from "@/components/cards/CaseCard";
|
||||||
|
import { CardLink } from "@/components/common/CardLink";
|
||||||
|
import { CreateCase } from "@/components/common/CreateCase";
|
||||||
|
import { fetchOpenCases } from "@/lib/db";
|
||||||
|
import { PizzaIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function Reports() {
|
||||||
|
const cases = (await fetchOpenCases()).reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<CreateCase />
|
||||||
|
<h1 className="text-2xl">Open Cases</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cases.length ? (
|
||||||
|
cases.map((entry) => (
|
||||||
|
<CardLink key={entry._id} href={`/panel/cases/${entry._id}`}>
|
||||||
|
<CaseCard entry={entry} />
|
||||||
|
</CardLink>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-8 flex justify-center">
|
||||||
|
<PizzaIcon className="text-gray-400" />
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-xs text-center pb-2 text-gray-400">
|
||||||
|
No cases currently open.
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { ReportCard } from "@/components/cards/ReportCard";
|
import { ReportCard } from "@/components/cards/ReportCard";
|
||||||
import { CardLink } from "@/components/common/CardLink";
|
import { CardLink } from "@/components/common/CardLink";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { fetchOpenReports } from "@/lib/db";
|
import { fetchOpenReports, fetchUsersById } from "@/lib/db";
|
||||||
import { PizzaIcon } from "lucide-react";
|
import { PizzaIcon } from "lucide-react";
|
||||||
import { Report } from "revolt-api";
|
import { Report } from "revolt-api";
|
||||||
|
|
||||||
|
@ -13,12 +13,15 @@ export default async function Reports() {
|
||||||
const byCategory: Record<string, Report[]> = {
|
const byCategory: Record<string, Report[]> = {
|
||||||
Urgent: [],
|
Urgent: [],
|
||||||
All: [],
|
All: [],
|
||||||
|
AssignedToCase: [],
|
||||||
};
|
};
|
||||||
const keyOrder = ["Urgent", "All"];
|
const keyOrder = ["Urgent", "All"];
|
||||||
|
|
||||||
const countsByAuthor: Record<string, number> = {};
|
const countsByAuthor: Record<string, number> = {};
|
||||||
for (const report of reports) {
|
for (const report of reports) {
|
||||||
if (report.content.report_reason.includes("Illegal")) {
|
if (report.case_id) {
|
||||||
|
byCategory.AssignedToCase.push(report);
|
||||||
|
} else if (report.content.report_reason.includes("Illegal")) {
|
||||||
byCategory.Urgent.push(report);
|
byCategory.Urgent.push(report);
|
||||||
} else {
|
} else {
|
||||||
countsByAuthor[report.author_id] =
|
countsByAuthor[report.author_id] =
|
||||||
|
@ -27,6 +30,8 @@ export default async function Reports() {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const report of reports) {
|
for (const report of reports) {
|
||||||
|
if (report.case_id) continue;
|
||||||
|
|
||||||
if (!report.content.report_reason.includes("Illegal")) {
|
if (!report.content.report_reason.includes("Illegal")) {
|
||||||
if (countsByAuthor[report.author_id] > 1) {
|
if (countsByAuthor[report.author_id] > 1) {
|
||||||
if (!keyOrder.includes(report.author_id)) {
|
if (!keyOrder.includes(report.author_id)) {
|
||||||
|
@ -41,25 +46,32 @@ export default async function Reports() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authorNames: Record<string, string> = {};
|
||||||
|
for (const user of await fetchUsersById(Object.keys(countsByAuthor))) {
|
||||||
|
authorNames[user._id] = user.username + "#" + user.discriminator;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{/*<Input placeholder="Search for reports..." disabled />*/}
|
{/*<Input placeholder="Search for reports..." disabled />*/}
|
||||||
{reports.length ? (
|
{reports.length ? (
|
||||||
keyOrder.map((key) => {
|
keyOrder
|
||||||
return (
|
.filter((key) => byCategory[key].length)
|
||||||
<div key={key} className="flex flex-col gap-2">
|
.map((key) => {
|
||||||
<h1 className="text-2xl">{key}</h1>
|
return (
|
||||||
{byCategory[key].map((report) => (
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<CardLink
|
<h1 className="text-2xl">{authorNames[key] ?? key}</h1>
|
||||||
key={report._id}
|
{byCategory[key].map((report) => (
|
||||||
href={`/panel/reports/${report._id}`}
|
<CardLink
|
||||||
>
|
key={report._id}
|
||||||
<ReportCard report={report} />
|
href={`/panel/reports/${report._id}`}
|
||||||
</CardLink>
|
>
|
||||||
))}{" "}
|
<ReportCard report={report} />
|
||||||
</div>
|
</CardLink>
|
||||||
);
|
))}{" "}
|
||||||
})
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="mt-8 flex justify-center">
|
<h2 className="mt-8 flex justify-center">
|
||||||
|
@ -70,6 +82,20 @@ export default async function Reports() {
|
||||||
</h3>
|
</h3>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{byCategory["AssignedToCase"].length && (
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<h1 className="text-xl inline">Reports assigned to cases</h1>
|
||||||
|
</summary>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{byCategory["AssignedToCase"].map((report) => (
|
||||||
|
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
||||||
|
<ReportCard report={report} />
|
||||||
|
</CardLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Report } from "revolt-api";
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { CaseDocument } from "@/lib/db";
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
export function CaseCard({ entry: entry }: { entry: CaseDocument }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle
|
||||||
|
className={`overflow-ellipsis whitespace-nowrap overflow-x-clip ${
|
||||||
|
entry.status !== "Open" ? "text-gray-500" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.title || "No reason specified"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{entry._id.toString().substring(20, 26)} ·{" "}
|
||||||
|
{dayjs(decodeTime(entry._id)).fromNow()} · {entry.author}{" "}
|
||||||
|
{entry.status !== "Open" && entry.closed_at && (
|
||||||
|
<>· Closed {dayjs(entry.closed_at).fromNow()}</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import { Report } from "revolt-api";
|
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { decodeTime } from "ulid";
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { ReportDocument } from "@/lib/db";
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
const lastWeek = new Date();
|
const lastWeek = new Date();
|
||||||
|
@ -13,7 +13,7 @@ lastWeek.setDate(lastWeek.getDate() - 7);
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
export function ReportCard({ report }: { report: Report }) {
|
export function ReportCard({ report }: { report: ReportDocument }) {
|
||||||
const dueDate = +(report.content.report_reason.includes("Illegal")
|
const dueDate = +(report.content.report_reason.includes("Illegal")
|
||||||
? yesterday
|
? yesterday
|
||||||
: lastWeek);
|
: lastWeek);
|
||||||
|
@ -48,6 +48,14 @@ export function ReportCard({ report }: { report: Report }) {
|
||||||
{report.status !== "Created" && report.closed_at && (
|
{report.status !== "Created" && report.closed_at && (
|
||||||
<>· Closed {dayjs(report.closed_at).fromNow()}</>
|
<>· Closed {dayjs(report.closed_at).fromNow()}</>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
|
{report.case_id && (
|
||||||
|
<>
|
||||||
|
·{" "}
|
||||||
|
<Badge className="align-middle" variant="secondary">
|
||||||
|
Assigned
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}{" "}
|
||||||
{report.status === "Created" && decodeTime(report._id) < dueDate && (
|
{report.status === "Created" && decodeTime(report._id) < dueDate && (
|
||||||
<>
|
<>
|
||||||
·{" "}
|
·{" "}
|
||||||
|
|
|
@ -1,22 +1,33 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
import { toast } from "../ui/use-toast";
|
import { toast } from "../ui/use-toast";
|
||||||
import { SafetyNotes, fetchSafetyNote, updateSafetyNote } from "@/lib/db";
|
import { SafetyNotes, fetchSafetyNote, updateSafetyNote } from "@/lib/db";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../ui/card";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export default function SafetyNotesCard({ objectId, type, title }: {
|
export default function SafetyNotesCard({
|
||||||
objectId: string,
|
objectId,
|
||||||
type: SafetyNotes["_id"]["type"],
|
type,
|
||||||
title?: string
|
title,
|
||||||
|
}: {
|
||||||
|
objectId: string;
|
||||||
|
type: SafetyNotes["_id"]["type"];
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
@ -40,18 +51,20 @@ export default function SafetyNotesCard({ objectId, type, title }: {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}</CardTitle>
|
<CardTitle>
|
||||||
|
{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{
|
{editing ? (
|
||||||
editing
|
<Textarea
|
||||||
? <Textarea
|
rows={8}
|
||||||
placeholder={
|
placeholder={
|
||||||
error
|
error
|
||||||
? error
|
? error
|
||||||
: ready
|
: ready
|
||||||
? "Enter notes here... (save on unfocus)"
|
? "Enter notes here... (save on unfocus)"
|
||||||
: "Fetching notes..."
|
: "Fetching notes..."
|
||||||
}
|
}
|
||||||
className="!min-h-[80px] max-h-[50vh]"
|
className="!min-h-[80px] max-h-[50vh]"
|
||||||
disabled={!ready || error != null}
|
disabled={!ready || error != null}
|
||||||
|
@ -60,7 +73,7 @@ export default function SafetyNotesCard({ objectId, type, title }: {
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
onBlur={async () => {
|
onBlur={async () => {
|
||||||
if (draft === value?.text ?? "") return setEditing(false);
|
if (draft === value?.text ?? "") return setEditing(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateSafetyNote(objectId, type, draft);
|
await updateSafetyNote(objectId, type, draft);
|
||||||
setValue({
|
setValue({
|
||||||
|
@ -83,33 +96,37 @@ export default function SafetyNotesCard({ objectId, type, title }: {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
: <div onClick={() => setEditing(true)}>
|
) : (
|
||||||
{
|
<div onClick={() => setEditing(true)}>
|
||||||
error
|
{error ? (
|
||||||
? <>{error}</>
|
<>{error}</>
|
||||||
: value?.text
|
) : value?.text ? (
|
||||||
? <ReactMarkdown
|
<ReactMarkdown
|
||||||
className="prose prose-a:text-[#fd6671] prose-img:max-h-96 max-w-none"
|
className="prose prose-a:text-[#fd6671] prose-img:max-h-96 max-w-none"
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
>
|
>
|
||||||
{value.text}
|
{value.text}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
: ready
|
) : ready ? (
|
||||||
? <i>Click to add a note</i>
|
<i>Click to add a note</i>
|
||||||
: <i>Fetching notes...</i>
|
) : (
|
||||||
}
|
<i>Fetching notes...</i>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="-my-2">
|
<CardFooter className="-my-2">
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{
|
{value ? (
|
||||||
value
|
<>
|
||||||
? <>Last edited {dayjs(value.edited_at).fromNow()} by {value.edited_by}</>
|
Last edited {dayjs(value.edited_at).fromNow()} by{" "}
|
||||||
: <>No object note set</>
|
{value.edited_by}
|
||||||
}
|
</>
|
||||||
</CardDescription>
|
) : (
|
||||||
|
<>No object note set</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,13 +7,20 @@ import Link from "next/link";
|
||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: string, withLink?: boolean }) {
|
export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: string, withLink?: boolean }) {
|
||||||
|
const gradientColour = user.flags == 1
|
||||||
|
? 'rgba(251, 146, 60, 0.6)'
|
||||||
|
: user.flags == 4
|
||||||
|
? 'rgba(239, 68, 68, 0.6)'
|
||||||
|
: 'transparent';
|
||||||
|
const gradient = `linear-gradient(to right, white, rgba(255,0,0,0)), repeating-linear-gradient(225deg, transparent, transparent 32px, ${gradientColour} 32px, ${gradientColour} 64px)`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="bg-no-repeat bg-right text-left"
|
className="bg-no-repeat bg-right text-left"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: user.profile?.background
|
backgroundImage: user.profile?.background
|
||||||
? `linear-gradient(to right, white, rgba(255,0,0,0)), url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
|
? `${gradient}, url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
|
||||||
: "",
|
: gradient,
|
||||||
backgroundSize: "75%",
|
backgroundSize: "75%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -30,6 +37,8 @@ export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: s
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{user.bot && <Badge className="align-middle">Bot</Badge>}{" "}
|
{user.bot && <Badge className="align-middle">Bot</Badge>}{" "}
|
||||||
|
{user.flags == 1 && <Badge className="align-middle bg-orange-400">Suspended</Badge>}{" "}
|
||||||
|
{user.flags == 4 && <Badge className="align-middle bg-red-700">Banned</Badge>}{" "}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{user.username}#{user.discriminator} {user.display_name}
|
{user.username}#{user.discriminator} {user.display_name}
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createCase } from "@/lib/db";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function CreateCase() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium leading-none">Create Case</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<Label htmlFor="description">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
className="col-span-2 h-8"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (!title) return;
|
||||||
|
createCase(title).then((id) =>
|
||||||
|
router.push(`/panel/cases/${id}`)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Siren,
|
Siren,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
BookCopy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export function NavigationLinks() {
|
export function NavigationLinks() {
|
||||||
|
@ -34,6 +35,12 @@ export function NavigationLinks() {
|
||||||
>
|
>
|
||||||
<Siren className="h-4 w-4" />
|
<Siren className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||||
|
href="/panel/cases"
|
||||||
|
>
|
||||||
|
<BookCopy className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||||
href="/panel/inspect"
|
href="/panel/inspect"
|
||||||
|
|
|
@ -16,7 +16,7 @@ export function NavigationToolbar({ children }: { children: string }) {
|
||||||
<Button variant="outline" size="icon" onClick={() => router.back()}>
|
<Button variant="outline" size="icon" onClick={() => router.back()}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Popover>
|
{/* <Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<Star
|
<Star
|
||||||
|
@ -49,7 +49,7 @@ export function NavigationToolbar({ children }: { children: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover> */}
|
||||||
<h2 className="text-2xl">{children}</h2>
|
<h2 className="text-2xl">{children}</h2>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Textarea } from "../../ui/textarea";
|
||||||
|
import { Button } from "../../ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useToast } from "../../ui/use-toast";
|
||||||
|
import { CaseDocument } from "@/lib/db";
|
||||||
|
import { CaseCard } from "@/components/cards/CaseCard";
|
||||||
|
import { closeCase, reopenCase, updateCaseNotes } from "@/lib/actions";
|
||||||
|
|
||||||
|
export function CaseActions({ Case }: { Case: CaseDocument }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [caseDraft, setDraft] = useState(Case);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CaseCard entry={Case} />
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder="Enter notes here... (save on unfocus)"
|
||||||
|
className="!min-h-0 !h-[76px]"
|
||||||
|
defaultValue={Case.notes}
|
||||||
|
onBlur={async (e) => {
|
||||||
|
const notes = e.currentTarget.value;
|
||||||
|
if (notes === caseDraft.notes ?? "") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCaseNotes(Case._id, notes);
|
||||||
|
setDraft((c) => ({ ...c, notes }));
|
||||||
|
toast({
|
||||||
|
title: "Updated report notes",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to update report notes",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{caseDraft.status === "Open" ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="flex-1 bg-green-400 hover:bg-green-300"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const $set = await closeCase(Case._id);
|
||||||
|
setDraft((c) => ({ ...c, ...$set }));
|
||||||
|
toast({
|
||||||
|
title: "Closed case",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to close case",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close Case
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const $set = await reopenCase(Case._id);
|
||||||
|
setDraft((c) => ({ ...c, ...$set }));
|
||||||
|
toast({
|
||||||
|
title: "Opened case again",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to re-open case",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Re-open Case
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToast } from "../../ui/use-toast";
|
import { useToast } from "../../ui/use-toast";
|
||||||
import {
|
import {
|
||||||
|
assignReportToCase,
|
||||||
rejectReport,
|
rejectReport,
|
||||||
reopenReport,
|
reopenReport,
|
||||||
resolveReport,
|
resolveReport,
|
||||||
|
@ -32,6 +33,10 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "../../ui/alert-dialog";
|
} from "../../ui/alert-dialog";
|
||||||
import { ReportCard } from "../../cards/ReportCard";
|
import { ReportCard } from "../../cards/ReportCard";
|
||||||
|
import { Popover } from "@radix-ui/react-popover";
|
||||||
|
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { CaseDocument, ReportDocument, fetchOpenCases } from "@/lib/db";
|
||||||
|
import { CaseCard } from "@/components/cards/CaseCard";
|
||||||
|
|
||||||
const template: Record<string, (ref: string) => string> = {
|
const template: Record<string, (ref: string) => string> = {
|
||||||
resolved: (ref) =>
|
resolved: (ref) =>
|
||||||
|
@ -43,7 +48,7 @@ const template: Record<string, (ref: string) => string> = {
|
||||||
"not enough evidence": (ref) =>
|
"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.`,
|
`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) =>
|
clarify: (ref) =>
|
||||||
`Your report (${ref}) needs clarification, please provide additional information.`,
|
`Your report (${ref}) needs clarification, please provide additional information. You can report the messages again, report additional messages, or send an email to contact@revolt.chat.`,
|
||||||
acknowledged: (ref) =>
|
acknowledged: (ref) =>
|
||||||
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
|
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
|
||||||
default: (ref) =>
|
default: (ref) =>
|
||||||
|
@ -54,11 +59,12 @@ export function ReportActions({
|
||||||
report,
|
report,
|
||||||
reference,
|
reference,
|
||||||
}: {
|
}: {
|
||||||
report: Report;
|
report: ReportDocument;
|
||||||
reference: string;
|
reference: string;
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [reportDraft, setDraft] = useState(report);
|
const [reportDraft, setDraft] = useState(report);
|
||||||
|
const [availableCases, setAvailableCases] = useState<CaseDocument[]>([]);
|
||||||
|
|
||||||
function rejectHandler(reason: string) {
|
function rejectHandler(reason: string) {
|
||||||
return async () => {
|
return async () => {
|
||||||
|
@ -83,6 +89,7 @@ export function ReportActions({
|
||||||
<ReportCard report={reportDraft} />
|
<ReportCard report={reportDraft} />
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
|
rows={8}
|
||||||
placeholder="Enter notes here... (save on unfocus)"
|
placeholder="Enter notes here... (save on unfocus)"
|
||||||
className="!min-h-0 !h-[76px]"
|
className="!min-h-0 !h-[76px]"
|
||||||
defaultValue={report.notes}
|
defaultValue={report.notes}
|
||||||
|
@ -106,6 +113,74 @@ export function ReportActions({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{reportDraft.case_id ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const $set = await assignReportToCase(report._id);
|
||||||
|
setDraft((report) => ({ ...report, ...$set }));
|
||||||
|
toast({
|
||||||
|
title: "Removed report from case",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to resolve report",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove from case
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
fetchOpenCases().then(setAvailableCases);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add to case
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium leading-none">Open Cases</h4>
|
||||||
|
{availableCases.map((entry) => (
|
||||||
|
<a
|
||||||
|
key={entry._id}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const $set = await assignReportToCase(
|
||||||
|
report._id,
|
||||||
|
entry._id
|
||||||
|
);
|
||||||
|
setDraft((report) => ({ ...report, ...$set }));
|
||||||
|
toast({
|
||||||
|
title: "Assigned report to case",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to resolve report",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CaseCard entry={entry} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{reportDraft.status === "Created" ? (
|
{reportDraft.status === "Created" ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
unsuspendUser,
|
unsuspendUser,
|
||||||
updateBotDiscoverability,
|
updateBotDiscoverability,
|
||||||
updateUserBadges,
|
updateUserBadges,
|
||||||
|
wipeUser,
|
||||||
wipeUserProfile,
|
wipeUserProfile,
|
||||||
} from "@/lib/actions";
|
} from "@/lib/actions";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
@ -264,6 +265,51 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button className="flex-1 bg-pink-600" disabled={userInaccessible}>
|
||||||
|
Wipe Messages
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Are you sure you want to wipe this user's messages?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
All messages sent by this user will be deleted immediately.
|
||||||
|
<br className="text-base/8" />
|
||||||
|
<span className="text-red-700">
|
||||||
|
This action is irreversible and{" "}
|
||||||
|
<b className="font-bold">will not publish any events</b>!
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="hover:bg-red-700 transition-all"
|
||||||
|
onClick={() =>
|
||||||
|
wipeUser(user._id, 0, true)
|
||||||
|
.then(() => {
|
||||||
|
setUserDraft((user) => ({ ...user, flags: 4 }));
|
||||||
|
toast({ title: "Wiped user's messages" });
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
toast({
|
||||||
|
title: "Failed to wipe user's messages!",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button className="flex-1 bg-yellow-600">Bees</Button>
|
<Button className="flex-1 bg-yellow-600">Bees</Button>
|
||||||
|
@ -424,29 +470,37 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" disabled={!user.bot?.owner}>Reset bot token</Button>
|
<Button variant="ghost" disabled={!user.bot?.owner}>
|
||||||
|
Reset bot token
|
||||||
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Reset token</AlertDialogTitle>
|
<AlertDialogTitle>Reset token</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="flex flex-col gap-2">
|
<AlertDialogDescription className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
Re-roll this bot's authentication token. This will not disconnect active connections.
|
Re-roll this bot's authentication token. This will
|
||||||
|
not disconnect active connections.
|
||||||
</span>
|
</span>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => resetBotToken(user._id)
|
onClick={() =>
|
||||||
.then(() => toast({
|
resetBotToken(user._id)
|
||||||
title: "Reset bot token",
|
.then(() =>
|
||||||
}))
|
toast({
|
||||||
.catch((e) => toast({
|
title: "Reset bot token",
|
||||||
title: "Failed to reset token",
|
})
|
||||||
description: String(e),
|
)
|
||||||
variant: "destructive",
|
.catch((e) =>
|
||||||
}))
|
toast({
|
||||||
|
title: "Failed to reset token",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
|
@ -457,18 +511,16 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" disabled={!user.bot?.owner}>Transfer bot</Button>
|
<Button variant="ghost" disabled={!user.bot?.owner}>
|
||||||
|
Transfer bot
|
||||||
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Transfer bot</AlertDialogTitle>
|
<AlertDialogTitle>Transfer bot</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="flex flex-col gap-2">
|
<AlertDialogDescription className="flex flex-col gap-2">
|
||||||
<span>
|
<span>Transfer this bot to a new owner.</span>
|
||||||
Transfer this bot to a new owner.
|
<UserSelector onChange={setTransferTarget} />
|
||||||
</span>
|
|
||||||
<UserSelector
|
|
||||||
onChange={setTransferTarget}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={transferResetToken}
|
checked={transferResetToken}
|
||||||
onChange={(e) => setTransferResetToken(!!e)}
|
onChange={(e) => setTransferResetToken(!!e)}
|
||||||
|
@ -481,19 +533,28 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
disabled={!transferTarget}
|
disabled={!transferTarget}
|
||||||
onClick={() => transferBot(user._id, transferTarget!._id, transferResetToken)
|
onClick={() =>
|
||||||
.then(() => toast({
|
transferBot(
|
||||||
title: "Reset bot token",
|
user._id,
|
||||||
}))
|
transferTarget!._id,
|
||||||
.catch((e) => toast({
|
transferResetToken
|
||||||
title: "Failed to reset token",
|
)
|
||||||
description: String(e),
|
.then(() =>
|
||||||
variant: "destructive",
|
toast({
|
||||||
}))
|
title: "Reset bot token",
|
||||||
.finally(() => {
|
})
|
||||||
setTransferResetToken(true);
|
)
|
||||||
setTransferTarget(null);
|
.catch((e) =>
|
||||||
})
|
toast({
|
||||||
|
title: "Failed to reset token",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
setTransferResetToken(true);
|
||||||
|
setTransferTarget(null);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Transfer
|
Transfer
|
||||||
|
|
|
@ -3,15 +3,8 @@ import { SafetyNotes, insertAuditLog } from "./db";
|
||||||
|
|
||||||
type Permission =
|
type Permission =
|
||||||
| `authifier${
|
| `authifier${
|
||||||
| ""
|
|
||||||
| `/classification${
|
|
||||||
| ""
|
| ""
|
||||||
| "/fetch"
|
| `/classification${"" | "/fetch" | "/create" | "/update" | "/delete"}`}`
|
||||||
| "/create"
|
|
||||||
| "/update"
|
|
||||||
| "/delete"
|
|
||||||
}`
|
|
||||||
}`
|
|
||||||
| "publish_message"
|
| "publish_message"
|
||||||
| "chat_message"
|
| "chat_message"
|
||||||
| `accounts${
|
| `accounts${
|
||||||
|
@ -31,19 +24,30 @@ type Permission =
|
||||||
| `/create${"" | "/dm" | "/invites"}`
|
| `/create${"" | "/dm" | "/invites"}`
|
||||||
| `/update${"" | "/invites"}`}`
|
| `/update${"" | "/invites"}`}`
|
||||||
| `messages${"" | `/fetch${"" | "/by-id" | "/by-user"}`}`
|
| `messages${"" | `/fetch${"" | "/by-id" | "/by-user"}`}`
|
||||||
|
| `cases${
|
||||||
|
| ""
|
||||||
|
| "/create"
|
||||||
|
| `/fetch${"" | "/by-id" | "/open"}`
|
||||||
|
| `/update${"" | "/close" | "/reopen" | "/notes"}`}`
|
||||||
| `reports${
|
| `reports${
|
||||||
| ""
|
| ""
|
||||||
| `/fetch${
|
| `/fetch${
|
||||||
| ""
|
| ""
|
||||||
| "/by-id"
|
| "/by-id"
|
||||||
| "/open"
|
| "/open"
|
||||||
| `/related${"" | "/by-content" | "/by-user" | "/against-user"}`
|
| `/related${
|
||||||
|
| ""
|
||||||
|
| "/by-content"
|
||||||
|
| "/by-user"
|
||||||
|
| "/by-case"
|
||||||
|
| "/against-user"}`
|
||||||
| `/snapshots${"" | "/by-report" | "/by-user"}`}`
|
| `/snapshots${"" | "/by-report" | "/by-user"}`}`
|
||||||
| `/update${
|
| `/update${
|
||||||
| ""
|
| ""
|
||||||
| "/notes"
|
| "/notes"
|
||||||
| "/resolve"
|
| "/resolve"
|
||||||
| "/reject"
|
| "/reject"
|
||||||
|
| "/case"
|
||||||
| "/reopen"
|
| "/reopen"
|
||||||
| `/bulk-close${"" | "/by-user"}`}`}`
|
| `/bulk-close${"" | "/by-user"}`}`}`
|
||||||
| `sessions${"" | `/fetch${"" | "/by-account-id"}`}`
|
| `sessions${"" | `/fetch${"" | "/by-account-id"}`}`
|
||||||
|
@ -103,6 +107,8 @@ const PermissionSets = {
|
||||||
// View open reports
|
// View open reports
|
||||||
"view-open-reports": [
|
"view-open-reports": [
|
||||||
"users/fetch/by-id",
|
"users/fetch/by-id",
|
||||||
|
"cases/fetch/open",
|
||||||
|
"cases/fetch/by-id",
|
||||||
"reports/fetch/open",
|
"reports/fetch/open",
|
||||||
"reports/fetch/by-id",
|
"reports/fetch/by-id",
|
||||||
"reports/fetch/related",
|
"reports/fetch/related",
|
||||||
|
@ -114,7 +120,12 @@ const PermissionSets = {
|
||||||
"reports/update/notes",
|
"reports/update/notes",
|
||||||
"reports/update/resolve",
|
"reports/update/resolve",
|
||||||
"reports/update/reject",
|
"reports/update/reject",
|
||||||
|
"reports/update/case",
|
||||||
"reports/update/reopen",
|
"reports/update/reopen",
|
||||||
|
"cases/create",
|
||||||
|
"cases/update/notes",
|
||||||
|
"cases/update/close",
|
||||||
|
"cases/update/reopen",
|
||||||
] as Permission[],
|
] as Permission[],
|
||||||
|
|
||||||
// Revolt Discover
|
// Revolt Discover
|
||||||
|
@ -212,9 +223,7 @@ const PermissionSets = {
|
||||||
"safety_notes/update",
|
"safety_notes/update",
|
||||||
] as Permission[],
|
] as Permission[],
|
||||||
|
|
||||||
"authifier": [
|
authifier: ["authifier/classification"] as Permission[],
|
||||||
"authifier/classification",
|
|
||||||
] as Permission[],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Roles = {
|
const Roles = {
|
||||||
|
@ -235,22 +244,32 @@ const ACL: Record<string, Set<Permission>> = {
|
||||||
...Roles["revolt-discover"],
|
...Roles["revolt-discover"],
|
||||||
...Roles["user-support"],
|
...Roles["user-support"],
|
||||||
] as Permission[]),
|
] as Permission[]),
|
||||||
"lea@janderedev.xyz": new Set([
|
"lea@revolt.chat": new Set([
|
||||||
...Roles["moderator"],
|
...Roles["moderator"],
|
||||||
...Roles["revolt-discover"],
|
...Roles["revolt-discover"],
|
||||||
...Roles["user-support"],
|
...Roles["user-support"],
|
||||||
] as Permission[]),
|
] as Permission[]),
|
||||||
"infi@infi.sh": new Set([
|
"tom@revolt.chat": new Set([
|
||||||
...Roles["moderator"],
|
...Roles["moderator"],
|
||||||
...Roles["revolt-discover"],
|
...Roles["revolt-discover"],
|
||||||
...Roles["user-support"],
|
...Roles["user-support"],
|
||||||
] as Permission[]),
|
] as Permission[]),
|
||||||
"beartechtalks@gmail.com": new Set([
|
"jen@revolt.chat": new Set([
|
||||||
...Roles["moderator"],
|
...Roles["moderator"],
|
||||||
...Roles["revolt-discover"],
|
...Roles["revolt-discover"],
|
||||||
...Roles["user-support"],
|
...Roles["user-support"],
|
||||||
] as Permission[]),
|
] as Permission[]),
|
||||||
"me@zomatree.live": new Set([
|
"rexo@revolt.chat": new Set([
|
||||||
|
...Roles["moderator"],
|
||||||
|
...Roles["revolt-discover"],
|
||||||
|
...Roles["user-support"],
|
||||||
|
] as Permission[]),
|
||||||
|
"zomatree@revolt.chat": new Set([
|
||||||
|
...Roles["moderator"],
|
||||||
|
...Roles["revolt-discover"],
|
||||||
|
...Roles["user-support"],
|
||||||
|
] as Permission[]),
|
||||||
|
"vale@revolt.chat": new Set([
|
||||||
...Roles["moderator"],
|
...Roles["moderator"],
|
||||||
...Roles["revolt-discover"],
|
...Roles["revolt-discover"],
|
||||||
...Roles["user-support"],
|
...Roles["user-support"],
|
||||||
|
@ -287,5 +306,5 @@ export async function checkPermission(
|
||||||
if (!(await hasPermissionFromSession(permission)))
|
if (!(await hasPermissionFromSession(permission)))
|
||||||
throw `Missing permission ${permission}`;
|
throw `Missing permission ${permission}`;
|
||||||
|
|
||||||
await insertAuditLog(permission, context, args);
|
return await insertAuditLog(permission, context, args);
|
||||||
}
|
}
|
||||||
|
|
361
lib/actions.ts
361
lib/actions.ts
|
@ -4,8 +4,10 @@ import { readFile, readdir, writeFile } from "fs/promises";
|
||||||
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
|
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
|
||||||
import mongo, {
|
import mongo, {
|
||||||
Account,
|
Account,
|
||||||
|
CaseDocument,
|
||||||
ChannelInvite,
|
ChannelInvite,
|
||||||
EmailClassification,
|
EmailClassification,
|
||||||
|
ReportDocument,
|
||||||
createDM,
|
createDM,
|
||||||
fetchAccountById,
|
fetchAccountById,
|
||||||
findDM,
|
findDM,
|
||||||
|
@ -98,6 +100,48 @@ export async function updateReportNotes(reportId: string, notes: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCaseNotes(caseId: string, notes: string) {
|
||||||
|
await checkPermission("cases/update/notes", caseId, { notes });
|
||||||
|
|
||||||
|
return await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<CaseDocument>("safety_cases")
|
||||||
|
.updateOne(
|
||||||
|
{ _id: caseId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignReportToCase(reportId: string, caseId?: string) {
|
||||||
|
await checkPermission("reports/update/case", reportId);
|
||||||
|
|
||||||
|
const $set = {
|
||||||
|
case_id: (caseId ?? null)!,
|
||||||
|
} as ReportDocument;
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ReportDocument>("safety_reports")
|
||||||
|
.updateOne(
|
||||||
|
{ _id: reportId },
|
||||||
|
(caseId
|
||||||
|
? {
|
||||||
|
$set,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
$unset: {
|
||||||
|
case_id: 1,
|
||||||
|
},
|
||||||
|
}) as never // fuck you
|
||||||
|
);
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveReport(reportId: string) {
|
export async function resolveReport(reportId: string) {
|
||||||
await checkPermission("reports/update/resolve", reportId);
|
await checkPermission("reports/update/resolve", reportId);
|
||||||
|
|
||||||
|
@ -116,6 +160,24 @@ export async function resolveReport(reportId: string) {
|
||||||
return $set;
|
return $set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function closeCase(caseId: string) {
|
||||||
|
await checkPermission("cases/update/close", caseId);
|
||||||
|
|
||||||
|
const $set = {
|
||||||
|
status: "Closed",
|
||||||
|
closed_at: new Date().toISOString(),
|
||||||
|
} as CaseDocument;
|
||||||
|
|
||||||
|
await mongo().db("revolt").collection<CaseDocument>("safety_cases").updateOne(
|
||||||
|
{ _id: caseId },
|
||||||
|
{
|
||||||
|
$set,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
export async function rejectReport(reportId: string, reason: string) {
|
export async function rejectReport(reportId: string, reason: string) {
|
||||||
await checkPermission("reports/update/reject", reportId, { reason });
|
await checkPermission("reports/update/reject", reportId, { reason });
|
||||||
|
|
||||||
|
@ -158,6 +220,23 @@ export async function reopenReport(reportId: string) {
|
||||||
return $set;
|
return $set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reopenCase(caseId: string) {
|
||||||
|
await checkPermission("cases/update/reopen", caseId);
|
||||||
|
|
||||||
|
const $set = {
|
||||||
|
status: "Open",
|
||||||
|
} as CaseDocument;
|
||||||
|
|
||||||
|
await mongo().db("revolt").collection<CaseDocument>("safety_cases").updateOne(
|
||||||
|
{ _id: caseId },
|
||||||
|
{
|
||||||
|
$set,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeReportsByUser(userId: string) {
|
export async function closeReportsByUser(userId: string) {
|
||||||
await checkPermission("reports/update/bulk-close/by-user", userId);
|
await checkPermission("reports/update/bulk-close/by-user", userId);
|
||||||
|
|
||||||
|
@ -208,8 +287,8 @@ export async function deleteMFARecoveryCodes(userId: string) {
|
||||||
$unset: {
|
$unset: {
|
||||||
"mfa.recovery_codes": 1,
|
"mfa.recovery_codes": 1,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableMFA(userId: string) {
|
export async function disableMFA(userId: string) {
|
||||||
|
@ -225,8 +304,8 @@ export async function disableMFA(userId: string) {
|
||||||
$unset: {
|
$unset: {
|
||||||
"mfa.totp_token": 1,
|
"mfa.totp_token": 1,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeAccountEmail(userId: string, email: string) {
|
export async function changeAccountEmail(userId: string, email: string) {
|
||||||
|
@ -287,9 +366,7 @@ export async function verifyAccountEmail(userId: string) {
|
||||||
export async function lookupEmail(email: string): Promise<string | false> {
|
export async function lookupEmail(email: string): Promise<string | false> {
|
||||||
await checkPermission("accounts/fetch/by-email", email);
|
await checkPermission("accounts/fetch/by-email", email);
|
||||||
|
|
||||||
const accounts = mongo()
|
const accounts = mongo().db("revolt").collection<Account>("accounts");
|
||||||
.db("revolt")
|
|
||||||
.collection<Account>("accounts");
|
|
||||||
|
|
||||||
let result = await accounts.findOne({ email: email });
|
let result = await accounts.findOne({ email: email });
|
||||||
if (result) return result._id;
|
if (result) return result._id;
|
||||||
|
@ -369,15 +446,21 @@ export async function updateUserBadges(userId: string, badges: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wipeUser(userId: string, flags = 4) {
|
export async function wipeUser(
|
||||||
|
userId: string,
|
||||||
|
flags = 4,
|
||||||
|
onlyMessages = false
|
||||||
|
) {
|
||||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||||
|
|
||||||
await checkPermission("users/action/wipe", userId, { flags });
|
await checkPermission("users/action/wipe", userId, { flags });
|
||||||
|
|
||||||
const user = await mongo()
|
const user = onlyMessages
|
||||||
.db("revolt")
|
? null
|
||||||
.collection<User>("users")
|
: await mongo()
|
||||||
.findOne({ _id: userId });
|
.db("revolt")
|
||||||
|
.collection<User>("users")
|
||||||
|
.findOne({ _id: userId });
|
||||||
|
|
||||||
const messages = await mongo()
|
const messages = await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
|
@ -385,24 +468,28 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
.find({ author: userId }, { sort: { _id: -1 } })
|
.find({ author: userId }, { sort: { _id: -1 } })
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
const dms = await mongo()
|
const dms = onlyMessages
|
||||||
.db("revolt")
|
? null
|
||||||
.collection<Channel>("channels")
|
: await mongo()
|
||||||
.find({
|
.db("revolt")
|
||||||
channel_type: "DirectMessage",
|
.collection<Channel>("channels")
|
||||||
recipients: userId,
|
.find({
|
||||||
})
|
channel_type: "DirectMessage",
|
||||||
.toArray();
|
recipients: userId,
|
||||||
|
})
|
||||||
|
.toArray();
|
||||||
|
|
||||||
const memberships = await mongo()
|
const memberships = onlyMessages
|
||||||
.db("revolt")
|
? null
|
||||||
.collection<{ _id: { user: string; server: string } }>("server_members")
|
: await mongo()
|
||||||
.find({ "_id.user": userId })
|
.db("revolt")
|
||||||
.toArray();
|
.collection<{ _id: { user: string; server: string } }>("server_members")
|
||||||
|
.find({ "_id.user": userId })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
// retrieve messages, dm channels, relationships, server memberships
|
// retrieve messages, dm channels, relationships, server memberships
|
||||||
const backup = {
|
const backup = {
|
||||||
_event: "wipe",
|
_event: onlyMessages ? "messages" : "wipe",
|
||||||
user,
|
user,
|
||||||
messages,
|
messages,
|
||||||
dms,
|
dms,
|
||||||
|
@ -422,12 +509,14 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
.filter((attachment) => attachment)
|
.filter((attachment) => attachment)
|
||||||
.map((attachment) => attachment!._id);
|
.map((attachment) => attachment!._id);
|
||||||
|
|
||||||
if (backup.user?.avatar) {
|
if (!onlyMessages) {
|
||||||
attachmentIds.push(backup.user.avatar._id);
|
if (backup.user?.avatar) {
|
||||||
}
|
attachmentIds.push(backup.user.avatar._id);
|
||||||
|
}
|
||||||
|
|
||||||
if (backup.user?.profile?.background) {
|
if (backup.user?.profile?.background) {
|
||||||
attachmentIds.push(backup.user.profile.background._id);
|
attachmentIds.push(backup.user.profile.background._id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentIds.length) {
|
if (attachmentIds.length) {
|
||||||
|
@ -450,44 +539,46 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
author: userId,
|
author: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// delete server memberships
|
if (!onlyMessages) {
|
||||||
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
|
// delete server memberships
|
||||||
"_id.user": userId,
|
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
|
||||||
});
|
"_id.user": userId,
|
||||||
|
|
||||||
// disable account
|
|
||||||
await disableAccount(userId);
|
|
||||||
|
|
||||||
// clear user profile
|
|
||||||
await mongo()
|
|
||||||
.db("revolt")
|
|
||||||
.collection<User>("users")
|
|
||||||
.updateOne(
|
|
||||||
{
|
|
||||||
_id: userId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
flags,
|
|
||||||
},
|
|
||||||
$unset: {
|
|
||||||
avatar: 1,
|
|
||||||
profile: 1,
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// broadcast wipe event
|
|
||||||
for (const topic of [
|
|
||||||
...backup.dms.map((x) => x._id),
|
|
||||||
...backup.memberships.map((x) => x._id.server),
|
|
||||||
]) {
|
|
||||||
await publishMessage(topic, {
|
|
||||||
type: "UserPlatformWipe",
|
|
||||||
user_id: userId,
|
|
||||||
flags,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// disable account
|
||||||
|
await disableAccount(userId);
|
||||||
|
|
||||||
|
// clear user profile
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<User>("users")
|
||||||
|
.updateOne(
|
||||||
|
{
|
||||||
|
_id: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
flags,
|
||||||
|
},
|
||||||
|
$unset: {
|
||||||
|
avatar: 1,
|
||||||
|
profile: 1,
|
||||||
|
status: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// broadcast wipe event
|
||||||
|
for (const topic of [
|
||||||
|
...backup.dms!.map((x) => x._id),
|
||||||
|
...backup.memberships!.map((x) => x._id.server),
|
||||||
|
]) {
|
||||||
|
await publishMessage(topic, {
|
||||||
|
type: "UserPlatformWipe",
|
||||||
|
user_id: userId,
|
||||||
|
flags,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -598,10 +689,7 @@ export async function updateServerOwner(serverId: string, userId: string) {
|
||||||
await mongo()
|
await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<Server>("servers")
|
.collection<Server>("servers")
|
||||||
.updateOne(
|
.updateOne({ _id: serverId }, { $set: { owner: userId } });
|
||||||
{ _id: serverId },
|
|
||||||
{ $set: { owner: userId } },
|
|
||||||
);
|
|
||||||
|
|
||||||
await publishMessage(serverId, {
|
await publishMessage(serverId, {
|
||||||
type: "ServerUpdate",
|
type: "ServerUpdate",
|
||||||
|
@ -613,8 +701,16 @@ export async function updateServerOwner(serverId: string, userId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addServerMember(serverId: string, userId: string, withEvent: boolean) {
|
export async function addServerMember(
|
||||||
await checkPermission("servers/update/add-member", { serverId, userId, withEvent });
|
serverId: string,
|
||||||
|
userId: string,
|
||||||
|
withEvent: boolean
|
||||||
|
) {
|
||||||
|
await checkPermission("servers/update/add-member", {
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
withEvent,
|
||||||
|
});
|
||||||
|
|
||||||
const server = await mongo()
|
const server = await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
|
@ -643,7 +739,7 @@ export async function addServerMember(serverId: string, userId: string, withEven
|
||||||
joined_at: Long.fromNumber(Date.now()) as unknown as string,
|
joined_at: Long.fromNumber(Date.now()) as unknown as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
await publishMessage(userId + '!', {
|
await publishMessage(userId + "!", {
|
||||||
type: "ServerCreate",
|
type: "ServerCreate",
|
||||||
id: serverId,
|
id: serverId,
|
||||||
channels: channels,
|
channels: channels,
|
||||||
|
@ -686,11 +782,11 @@ export async function quarantineServer(serverId: string, message: string) {
|
||||||
server,
|
server,
|
||||||
members,
|
members,
|
||||||
invites,
|
invites,
|
||||||
}
|
};
|
||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
`./exports/${new Date().toISOString()} - ${serverId}.json`,
|
`./exports/${new Date().toISOString()} - ${serverId}.json`,
|
||||||
JSON.stringify(backup),
|
JSON.stringify(backup)
|
||||||
);
|
);
|
||||||
|
|
||||||
await mongo()
|
await mongo()
|
||||||
|
@ -703,7 +799,7 @@ export async function quarantineServer(serverId: string, message: string) {
|
||||||
owner: "0".repeat(26),
|
owner: "0".repeat(26),
|
||||||
analytics: false,
|
analytics: false,
|
||||||
discoverable: false,
|
discoverable: false,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -728,10 +824,11 @@ export async function quarantineServer(serverId: string, message: string) {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
m.map(async (member) => {
|
m.map(async (member) => {
|
||||||
const messageId = ulid();
|
const messageId = ulid();
|
||||||
|
|
||||||
let dm = await findDM(PLATFORM_MOD_ID, member._id.user);
|
let dm = await findDM(PLATFORM_MOD_ID, member._id.user);
|
||||||
if (!dm) dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
|
if (!dm)
|
||||||
|
dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
|
||||||
|
|
||||||
await sendChatMessage({
|
await sendChatMessage({
|
||||||
_id: messageId,
|
_id: messageId,
|
||||||
author: PLATFORM_MOD_ID,
|
author: PLATFORM_MOD_ID,
|
||||||
|
@ -837,11 +934,15 @@ export async function resetBotToken(botId: string) {
|
||||||
$set: {
|
$set: {
|
||||||
token: nanoid(64),
|
token: nanoid(64),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transferBot(botId: string, ownerId: string, resetToken: boolean) {
|
export async function transferBot(
|
||||||
|
botId: string,
|
||||||
|
ownerId: string,
|
||||||
|
resetToken: boolean
|
||||||
|
) {
|
||||||
await checkPermission("bots/update/owner", { botId, ownerId, resetToken });
|
await checkPermission("bots/update/owner", { botId, ownerId, resetToken });
|
||||||
|
|
||||||
if (resetToken) {
|
if (resetToken) {
|
||||||
|
@ -858,15 +959,13 @@ export async function transferBot(botId: string, ownerId: string, resetToken: bo
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
owner: ownerId,
|
owner: ownerId,
|
||||||
...(
|
...(resetToken
|
||||||
resetToken
|
? {
|
||||||
? {
|
|
||||||
token: nanoid(64),
|
token: nanoid(64),
|
||||||
}
|
}
|
||||||
: {}
|
: {}),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
await mongo()
|
await mongo()
|
||||||
|
@ -880,22 +979,19 @@ export async function transferBot(botId: string, ownerId: string, resetToken: bo
|
||||||
$set: {
|
$set: {
|
||||||
"bot.owner": ownerId,
|
"bot.owner": ownerId,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless.
|
// This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless.
|
||||||
await publishMessage(
|
await publishMessage(botId, {
|
||||||
botId,
|
type: "UserUpdate",
|
||||||
{
|
id: botId,
|
||||||
type: "UserUpdate",
|
data: {
|
||||||
id: botId,
|
bot: {
|
||||||
data: {
|
owner: ownerId,
|
||||||
bot: {
|
|
||||||
owner: ownerId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreAccount(accountId: string) {
|
export async function restoreAccount(accountId: string) {
|
||||||
|
@ -965,15 +1061,19 @@ export async function fetchBackups() {
|
||||||
await checkPermission("backup/fetch", null);
|
await checkPermission("backup/fetch", null);
|
||||||
|
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
(await readdir("./exports", { withFileTypes: true }))
|
(
|
||||||
|
await readdir("./exports", { withFileTypes: true })
|
||||||
|
)
|
||||||
.filter((file) => file.isFile() && file.name.endsWith(".json"))
|
.filter((file) => file.isFile() && file.name.endsWith(".json"))
|
||||||
.map(async (file) => {
|
.map(async (file) => {
|
||||||
let type: string | null = null;
|
let type: string | null = null;
|
||||||
try {
|
try {
|
||||||
type = JSON.parse((await readFile(`./exports/${file.name}`)).toString("utf-8"))._event;
|
type = JSON.parse(
|
||||||
} catch(e) {}
|
(await readFile(`./exports/${file.name}`)).toString("utf-8")
|
||||||
|
)._event;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
return { name: file.name, type: type }
|
return { name: file.name, type: type };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -984,7 +1084,9 @@ export async function fetchBackup(name: string) {
|
||||||
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
|
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEmailClassifications(): Promise<EmailClassification[]> {
|
export async function fetchEmailClassifications(): Promise<
|
||||||
|
EmailClassification[]
|
||||||
|
> {
|
||||||
await checkPermission("authifier/classification/fetch", null);
|
await checkPermission("authifier/classification/fetch", null);
|
||||||
|
|
||||||
return await mongo()
|
return await mongo()
|
||||||
|
@ -994,27 +1096,34 @@ export async function fetchEmailClassifications(): Promise<EmailClassification[]
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEmailClassification(domain: string, classification: string) {
|
export async function createEmailClassification(
|
||||||
await checkPermission("authifier/classification/create", { domain, classification });
|
domain: string,
|
||||||
|
classification: string
|
||||||
|
) {
|
||||||
|
await checkPermission("authifier/classification/create", {
|
||||||
|
domain,
|
||||||
|
classification,
|
||||||
|
});
|
||||||
|
|
||||||
await mongo()
|
await mongo()
|
||||||
.db("authifier")
|
.db("authifier")
|
||||||
.collection<EmailClassification>("email_classification")
|
.collection<EmailClassification>("email_classification")
|
||||||
.insertOne(
|
.insertOne({ _id: domain, classification });
|
||||||
{ _id: domain, classification },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmailClassification(domain: string, classification: string) {
|
export async function updateEmailClassification(
|
||||||
await checkPermission("authifier/classification/update", { domain, classification });
|
domain: string,
|
||||||
|
classification: string
|
||||||
|
) {
|
||||||
|
await checkPermission("authifier/classification/update", {
|
||||||
|
domain,
|
||||||
|
classification,
|
||||||
|
});
|
||||||
|
|
||||||
await mongo()
|
await mongo()
|
||||||
.db("authifier")
|
.db("authifier")
|
||||||
.collection<EmailClassification>("email_classification")
|
.collection<EmailClassification>("email_classification")
|
||||||
.updateOne(
|
.updateOne({ _id: domain }, { $set: { classification } });
|
||||||
{ _id: domain },
|
|
||||||
{ $set: { classification } },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEmailClassification(domain: string) {
|
export async function deleteEmailClassification(domain: string) {
|
||||||
|
@ -1023,21 +1132,19 @@ export async function deleteEmailClassification(domain: string) {
|
||||||
await mongo()
|
await mongo()
|
||||||
.db("authifier")
|
.db("authifier")
|
||||||
.collection<EmailClassification>("email_classification")
|
.collection<EmailClassification>("email_classification")
|
||||||
.deleteOne(
|
.deleteOne({ _id: domain });
|
||||||
{ _id: domain },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchUserByTag(username: string, discriminator: string): Promise<string | false> {
|
export async function searchUserByTag(
|
||||||
|
username: string,
|
||||||
|
discriminator: string
|
||||||
|
): Promise<string | false> {
|
||||||
await checkPermission("users/fetch/by-tag", { username, discriminator });
|
await checkPermission("users/fetch/by-tag", { username, discriminator });
|
||||||
|
|
||||||
const result = await mongo()
|
const result = await mongo().db("revolt").collection<User>("users").findOne({
|
||||||
.db("revolt")
|
username,
|
||||||
.collection<User>("users")
|
discriminator,
|
||||||
.findOne({
|
});
|
||||||
username,
|
|
||||||
discriminator,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result?._id || false;
|
return result?._id || false;
|
||||||
}
|
}
|
||||||
|
|
151
lib/db.ts
151
lib/db.ts
|
@ -21,6 +21,19 @@ import { getServerSession } from "next-auth";
|
||||||
|
|
||||||
let client: MongoClient;
|
let client: MongoClient;
|
||||||
|
|
||||||
|
export type CaseDocument = {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
author: string;
|
||||||
|
status: "Open" | "Closed";
|
||||||
|
closed_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportDocument = Report & {
|
||||||
|
case_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function mongo() {
|
function mongo() {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
client = new MongoClient(process.env.MONGODB!);
|
client = new MongoClient(process.env.MONGODB!);
|
||||||
|
@ -55,6 +68,8 @@ export async function insertAuditLog(
|
||||||
context,
|
context,
|
||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return session!.user!.email!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBotById(id: string) {
|
export async function fetchBotById(id: string) {
|
||||||
|
@ -116,34 +131,33 @@ export type Account = {
|
||||||
export async function fetchAccountById(id: string) {
|
export async function fetchAccountById(id: string) {
|
||||||
await checkPermission("accounts/fetch/by-id", id);
|
await checkPermission("accounts/fetch/by-id", id);
|
||||||
|
|
||||||
return await mongo()
|
return (await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<Account>("accounts")
|
.collection<Account>("accounts")
|
||||||
.aggregate(
|
.aggregate([
|
||||||
[
|
{
|
||||||
{
|
$match: { _id: id },
|
||||||
$match: { _id: id },
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
password: 0,
|
||||||
|
"mfa.totp_token.secret": 0,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
$project: {
|
{
|
||||||
password: 0,
|
$set: {
|
||||||
"mfa.totp_token.secret": 0,
|
// Replace recovery code array with amount of codes
|
||||||
}
|
"mfa.recovery_codes": {
|
||||||
|
$cond: {
|
||||||
|
if: { $isArray: "$mfa.recovery_codes" },
|
||||||
|
then: { $size: "$mfa.recovery_codes" },
|
||||||
|
else: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
$set: {
|
])
|
||||||
// Replace recovery code array with amount of codes
|
.next()) as WithId<Account>;
|
||||||
"mfa.recovery_codes": {
|
|
||||||
$cond: {
|
|
||||||
if: { $isArray: "$mfa.recovery_codes" },
|
|
||||||
then: { $size: "$mfa.recovery_codes", },
|
|
||||||
else: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
).next() as WithId<Account>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSessionsByAccount(accountId: string) {
|
export async function fetchSessionsByAccount(accountId: string) {
|
||||||
|
@ -288,7 +302,7 @@ export async function fetchServers(query: Filter<Server>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// `vanity` should eventually be added to the backend as well
|
// `vanity` should eventually be added to the backend as well
|
||||||
export type ChannelInvite = Invite & { vanity?: boolean }
|
export type ChannelInvite = Invite & { vanity?: boolean };
|
||||||
|
|
||||||
export async function fetchInvites(query: Filter<ChannelInvite>) {
|
export async function fetchInvites(query: Filter<ChannelInvite>) {
|
||||||
await checkPermission("channels/fetch/invites", query);
|
await checkPermission("channels/fetch/invites", query);
|
||||||
|
@ -306,12 +320,12 @@ export async function fetchInvites(query: Filter<ChannelInvite>) {
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
const users = await mongo()
|
const users = await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<User>("users")
|
.collection<User>("users")
|
||||||
.find({ _id: { $in: invites.map((invite) => invite.creator) } })
|
.find({ _id: { $in: invites.map((invite) => invite.creator) } })
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
return { invites, channels, users }
|
return { invites, channels, users };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMessageById(id: string) {
|
export async function fetchMessageById(id: string) {
|
||||||
|
@ -358,7 +372,7 @@ export async function fetchOpenReports() {
|
||||||
|
|
||||||
return await mongo()
|
return await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<Report>("safety_reports")
|
.collection<ReportDocument>("safety_reports")
|
||||||
.find(
|
.find(
|
||||||
{ status: "Created" },
|
{ status: "Created" },
|
||||||
{
|
{
|
||||||
|
@ -370,6 +384,23 @@ export async function fetchOpenReports() {
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchOpenCases() {
|
||||||
|
await checkPermission("cases/fetch/open", "all");
|
||||||
|
|
||||||
|
return await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<CaseDocument>("safety_cases")
|
||||||
|
.find(
|
||||||
|
{ status: "Open" },
|
||||||
|
{
|
||||||
|
sort: {
|
||||||
|
_id: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRelatedReportsByContent(contentId: string) {
|
export async function fetchRelatedReportsByContent(contentId: string) {
|
||||||
await checkPermission("reports/fetch/related/by-content", contentId);
|
await checkPermission("reports/fetch/related/by-content", contentId);
|
||||||
|
|
||||||
|
@ -404,6 +435,23 @@ export async function fetchReportsByUser(userId: string) {
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchReportsByCase(caseId: string) {
|
||||||
|
await checkPermission("reports/fetch/related/by-case", caseId);
|
||||||
|
|
||||||
|
return await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ReportDocument>("safety_reports")
|
||||||
|
.find(
|
||||||
|
{ case_id: caseId },
|
||||||
|
{
|
||||||
|
sort: {
|
||||||
|
_id: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchReportsAgainstUser(userId: string) {
|
export async function fetchReportsAgainstUser(userId: string) {
|
||||||
await checkPermission("reports/fetch/related/against-user", userId);
|
await checkPermission("reports/fetch/related/against-user", userId);
|
||||||
|
|
||||||
|
@ -463,6 +511,27 @@ export async function fetchReportById(id: string) {
|
||||||
.findOne({ _id: id });
|
.findOne({ _id: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCase(title: string) {
|
||||||
|
const id = ulid();
|
||||||
|
const author = await checkPermission("cases/create", { title });
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<CaseDocument>("safety_cases")
|
||||||
|
.insertOne({ _id: id, author, status: "Open", title });
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCaseById(id: string) {
|
||||||
|
await checkPermission("cases/fetch/by-id", id);
|
||||||
|
|
||||||
|
return await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<CaseDocument>("safety_cases")
|
||||||
|
.findOne({ _id: id });
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchMembershipsByUser(userId: string) {
|
export async function fetchMembershipsByUser(userId: string) {
|
||||||
await checkPermission("users/fetch/memberships", userId);
|
await checkPermission("users/fetch/memberships", userId);
|
||||||
|
|
||||||
|
@ -562,13 +631,19 @@ export async function fetchAuthifierEmailClassification(provider: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SafetyNotes = {
|
export type SafetyNotes = {
|
||||||
_id: { id: string, type: "message" | "channel" | "server" | "user" | "account" | "global" };
|
_id: {
|
||||||
|
id: string;
|
||||||
|
type: "message" | "channel" | "server" | "user" | "account" | "global";
|
||||||
|
};
|
||||||
text: string;
|
text: string;
|
||||||
edited_by: string;
|
edited_by: string;
|
||||||
edited_at: Date;
|
edited_at: Date;
|
||||||
}
|
};
|
||||||
|
|
||||||
export async function fetchSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"]) {
|
export async function fetchSafetyNote(
|
||||||
|
objectId: string,
|
||||||
|
type: SafetyNotes["_id"]["type"]
|
||||||
|
) {
|
||||||
await checkPermission(`safety_notes/fetch/${type}`, objectId);
|
await checkPermission(`safety_notes/fetch/${type}`, objectId);
|
||||||
|
|
||||||
return mongo()
|
return mongo()
|
||||||
|
@ -577,7 +652,11 @@ export async function fetchSafetyNote(objectId: string, type: SafetyNotes["_id"]
|
||||||
.findOne({ _id: { id: objectId, type: type } });
|
.findOne({ _id: { id: objectId, type: type } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"], note: string) {
|
export async function updateSafetyNote(
|
||||||
|
objectId: string,
|
||||||
|
type: SafetyNotes["_id"]["type"],
|
||||||
|
note: string
|
||||||
|
) {
|
||||||
await checkPermission(`safety_notes/update/${type}`, objectId);
|
await checkPermission(`safety_notes/update/${type}`, objectId);
|
||||||
|
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
|
@ -594,6 +673,6 @@ export async function updateSafetyNote(objectId: string, type: SafetyNotes["_id"
|
||||||
edited_by: session?.user?.email ?? "",
|
edited_by: session?.user?.email ?? "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ upsert: true },
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue