forked from administration/panel
Compare commits
68 Commits
Author | SHA1 | Date |
---|---|---|
|
73ca6ec732 | |
|
e498e9d261 | |
|
78ce9f96d7 | |
|
96d5884461 | |
|
b79a14f00c | |
|
7f1619d33a | |
|
5cdb02b5bf | |
|
500f8b3e1c | |
|
c30d75c0ed | |
|
8113a86db9 | |
|
eb0bd7a7c9 | |
|
e9b623f60d | |
|
cdb05a5af7 | |
|
221ce0e75d | |
|
5b0bc0d36a | |
|
20579c9ebb | |
|
386f124fe9 | |
|
a3e5db0886 | |
|
9c531f3d99 | |
|
543baee97f | |
|
0210d385aa | |
|
bc48204410 | |
|
77075dad23 | |
|
54aea181fa | |
|
bbaff35812 | |
|
8246232461 | |
|
64671be091 | |
|
c54c690fe4 | |
|
373c66a251 | |
|
fc51faf9e1 | |
|
6afb040ffd | |
|
da3ae73c1e | |
|
f6c2bd1e96 | |
|
df9e8ed8d4 | |
|
3f73a5abb9 | |
|
cd4e01d9c6 | |
|
a748a68ba7 | |
|
d02d88c073 | |
|
20b889aca9 | |
|
9122eddb7c | |
|
84b0eec069 | |
|
d1ff57b239 | |
|
a03c539890 | |
|
d053e8ff5c | |
|
84d594ad0b | |
|
3f5ec1f2ef | |
|
7d7017168b | |
|
c3f18cec9b | |
|
cf71aa49cb | |
|
83161623e3 | |
|
04bc95c1c3 | |
|
239f667fe0 | |
|
edd1f9d297 | |
|
03fffc9849 | |
|
6cfe0a2ffa | |
|
72db810066 | |
|
91ba9b94c8 | |
|
977986736b | |
|
a04a10f492 | |
|
9249b4e58d | |
|
e575389a66 | |
|
7ba9565df7 | |
|
3a2e886803 | |
|
5c78020a3e | |
|
e8e0ef11d3 | |
|
8989abddec | |
|
5f774a0b72 | |
|
0237f9808a |
|
@ -0,0 +1,24 @@
|
||||||
|
# Revolt instance
|
||||||
|
REDIS=
|
||||||
|
MONGODB=
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
AUTHENTIK_ID=
|
||||||
|
AUTHENTIK_SECRET=
|
||||||
|
AUTHENTIK_ISSUER=https://sso.revolt.chat/application/o/admin-panel
|
||||||
|
|
||||||
|
# Next Auth
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
NEXTAUTH_URL=https://admin.revolt.chat
|
||||||
|
|
||||||
|
# Web server
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Notifications using ntfy.sh
|
||||||
|
NTFY_SERVER=https://ntfy.revolt.wtf
|
||||||
|
NTFY_TOPIC=reports
|
||||||
|
NTFY_USERNAME=admin-panel
|
||||||
|
NTFY_PASSWORD=
|
||||||
|
|
||||||
|
# Debugging
|
||||||
|
# BYPASS_ACL=1
|
|
@ -1,3 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": "next/core-web-vitals",
|
||||||
|
"rules": {
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
|
"jsx-a11y/alt-text": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,3 +37,5 @@ next-env.d.ts
|
||||||
# data
|
# data
|
||||||
exports/**
|
exports/**
|
||||||
!exports/.gitkeep
|
!exports/.gitkeep
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.insertSpaces": true
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { fetchBackup } from "@/lib/actions";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(req: Request, { params }: { params: { name: string } }) {
|
||||||
|
const name = decodeURIComponent(params.name);
|
||||||
|
const backup = await fetchBackup(name);
|
||||||
|
return NextResponse.json(backup);
|
||||||
|
}
|
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 11 KiB |
|
@ -0,0 +1,37 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { fetchBackups } from "@/lib/actions";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function Backups() {
|
||||||
|
const backups = await fetchBackups();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<TableRow key={backup.name}>
|
||||||
|
<TableCell>{backup.name}</TableCell>
|
||||||
|
<TableCell>{backup.type}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link target="_blank" href={`/api/download/backup/${encodeURIComponent(backup.name)}`}>
|
||||||
|
<Button>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -22,8 +22,10 @@ import dayjs from "dayjs";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { User } from "revolt-api";
|
import { User } from "revolt-api";
|
||||||
import { decodeTime } from "ulid";
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||||
|
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export default async function User({
|
export default async function User({
|
||||||
|
@ -40,9 +42,12 @@ export default async function User({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<NavigationToolbar>Inspecting Account</NavigationToolbar>
|
<NavigationToolbar>Inspecting Account</NavigationToolbar>
|
||||||
{user && <UserCard user={user} subtitle={account.email} />}
|
|
||||||
|
<RestrictedUserCard id={params.id} />
|
||||||
|
{user && <UserCard user={user} subtitle={`${account.email} · Created ${dayjs(decodeTime(account._id)).fromNow()}`} withLink />}
|
||||||
<AccountActions account={account} user={user as User} />
|
<AccountActions account={account} user={user as User} />
|
||||||
<EmailClassificationCard email={account.email} />
|
<EmailClassificationCard email={account.email} />
|
||||||
|
<SafetyNotesCard objectId={account._id} type="account" />
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ChannelCard } from "@/components/cards/ChannelCard";
|
import { ChannelCard } from "@/components/cards/ChannelCard";
|
||||||
import { JsonCard } from "@/components/cards/JsonCard";
|
import { JsonCard } from "@/components/cards/JsonCard";
|
||||||
|
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||||
import { ServerCard } from "@/components/cards/ServerCard";
|
import { ServerCard } from "@/components/cards/ServerCard";
|
||||||
import { UserCard } from "@/components/cards/UserCard";
|
import { UserCard } from "@/components/cards/UserCard";
|
||||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||||
|
@ -8,6 +9,11 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { fetchChannelById, fetchServerById, fetchUsersById } from "@/lib/db";
|
import { fetchChannelById, fetchServerById, fetchUsersById } from "@/lib/db";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export default async function Message({ params }: { params: { id: string } }) {
|
export default async function Message({ params }: { params: { id: string } }) {
|
||||||
const channel = await fetchChannelById(params.id);
|
const channel = await fetchChannelById(params.id);
|
||||||
|
@ -26,7 +32,7 @@ export default async function Message({ params }: { params: { id: string } }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<NavigationToolbar>Inspecting Channel</NavigationToolbar>
|
<NavigationToolbar>Inspecting Channel</NavigationToolbar>
|
||||||
<ChannelCard channel={channel!} subtitle="Channel" />
|
<ChannelCard channel={channel!} subtitle={`Channel · Created ${dayjs(decodeTime(channel._id)).fromNow()}`} />
|
||||||
|
|
||||||
{server && (
|
{server && (
|
||||||
<Link href={`/panel/inspect/server/${server._id}`}>
|
<Link href={`/panel/inspect/server/${server._id}`}>
|
||||||
|
@ -34,6 +40,8 @@ export default async function Message({ params }: { params: { id: string } }) {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SafetyNotesCard objectId={channel._id} type="channel" />
|
||||||
|
|
||||||
{participants.length ? (
|
{participants.length ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ChannelCard } from "@/components/cards/ChannelCard";
|
import { ChannelCard } from "@/components/cards/ChannelCard";
|
||||||
import { JsonCard } from "@/components/cards/JsonCard";
|
import { JsonCard } from "@/components/cards/JsonCard";
|
||||||
|
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||||
import { UserCard } from "@/components/cards/UserCard";
|
import { UserCard } from "@/components/cards/UserCard";
|
||||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
@ -28,6 +29,8 @@ export default async function Message({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<SafetyNotesCard objectId={message._id} type="message" />
|
||||||
|
|
||||||
{author && (
|
{author && (
|
||||||
<Link href={`/panel/inspect/user/${author!._id}`}>
|
<Link href={`/panel/inspect/user/${author!._id}`}>
|
||||||
<UserCard user={author!} subtitle="Message Author" />
|
<UserCard user={author!} subtitle="Message Author" />
|
||||||
|
|
|
@ -2,14 +2,60 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { lookupEmail, searchUserByTag } from "@/lib/actions";
|
||||||
import { API_URL } from "@/lib/constants";
|
import { API_URL } from "@/lib/constants";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function Inspect() {
|
export default function Inspect() {
|
||||||
const [id, setId] = useState("");
|
const [id, setId] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [discriminator, setDiscriminator] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const searchEmail = async () => {
|
||||||
|
try {
|
||||||
|
const result = await lookupEmail(email);
|
||||||
|
if (!result) toast({
|
||||||
|
title: "Not found",
|
||||||
|
description: "There doesn't seem to be any user with this email.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
else router.push(`/panel/inspect/account/${result}`);
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to look up",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchUsername = async () => {
|
||||||
|
try {
|
||||||
|
if (!discriminator) {
|
||||||
|
// Display all users with this username
|
||||||
|
router.push(`/panel/inspect/search?username=${encodeURIComponent(username)}`);
|
||||||
|
} else {
|
||||||
|
// Show the specific user that matches username#discriminator
|
||||||
|
const result = await searchUserByTag(username, discriminator);
|
||||||
|
if (!result) toast({
|
||||||
|
title: "Couldn't find user",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
else router.push(`/panel/inspect/user/${result}`);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to search",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createHandler = (type: string) => () =>
|
const createHandler = (type: string) => () =>
|
||||||
router.push(`/panel/inspect/${type}/${id}`);
|
router.push(`/panel/inspect/${type}/${id}`);
|
||||||
|
|
||||||
|
@ -20,7 +66,7 @@ export default function Inspect() {
|
||||||
value={id}
|
value={id}
|
||||||
onChange={(e) => setId(e.currentTarget.value)}
|
onChange={(e) => setId(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
@ -70,6 +116,51 @@ export default function Inspect() {
|
||||||
Message
|
Message
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div className="flex flex-col lg:flex-row gap-2 w-full">
|
||||||
|
<div className="flex gap-2 justify-between grow">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter an email..."
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key == "Enter" && email && searchEmail()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="flex"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!email}
|
||||||
|
onClick={searchEmail}
|
||||||
|
>
|
||||||
|
Lookup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-between grow">
|
||||||
|
<div className="flex flex-row items-center w-full gap-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key == "Enter" && username && searchUsername()}
|
||||||
|
/>
|
||||||
|
<span className="select-none text-gray-500">#</span>
|
||||||
|
<Input
|
||||||
|
placeholder="0000"
|
||||||
|
value={discriminator}
|
||||||
|
onChange={(e) => setDiscriminator(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key == "Enter" && username && searchUsername()}
|
||||||
|
className="flex-shrink-[2]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="flex"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!username}
|
||||||
|
onClick={searchUsername}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { UserCard } from "@/components/cards/UserCard";
|
||||||
|
import { fetchUsersByUsername } from "@/lib/actions";
|
||||||
|
import { SearchX } from "lucide-react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function Search({ searchParams }: { searchParams: any }) {
|
||||||
|
const username = searchParams.username;
|
||||||
|
|
||||||
|
if (!username) return redirect("/panel/inspect");
|
||||||
|
const users = await fetchUsersByUsername(username);
|
||||||
|
|
||||||
|
if (!users.length) return (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-8 flex justify-center">
|
||||||
|
<SearchX className="text-gray-400" />
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-xs text-center pb-2 text-gray-400">
|
||||||
|
No search results
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{
|
||||||
|
users.map((user) => (
|
||||||
|
<a key={user._id} href={`/panel/inspect/user/${user._id}`}>
|
||||||
|
<UserCard user={user} subtitle={user._id} />
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ServerCard } from "@/components/cards/ServerCard";
|
||||||
|
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||||
|
import ServerInviteList from "@/components/pages/inspector/InviteList";
|
||||||
|
import { fetchInvites, fetchServerById } from "@/lib/db";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function ServerInvites({ params }: { params: { id: string } }) {
|
||||||
|
const server = await fetchServerById(params.id);
|
||||||
|
if (!server) return notFound();
|
||||||
|
|
||||||
|
const { invites, channels, users } = await fetchInvites({
|
||||||
|
type: "Server",
|
||||||
|
server: params.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<NavigationToolbar>Inspecting Server Invites</NavigationToolbar>
|
||||||
|
<ServerCard server={server} subtitle={`${invites.length} invite${invites.length == 1 ? '' : 's'}`} />
|
||||||
|
<ServerInviteList invites={invites} server={server} channels={channels} users={users} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { JsonCard } from "@/components/cards/JsonCard";
|
import { JsonCard } from "@/components/cards/JsonCard";
|
||||||
|
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||||
import { ServerCard } from "@/components/cards/ServerCard";
|
import { ServerCard } from "@/components/cards/ServerCard";
|
||||||
import { UserCard } from "@/components/cards/UserCard";
|
import { UserCard } from "@/components/cards/UserCard";
|
||||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||||
|
@ -7,8 +8,13 @@ import { ServerActions } from "@/components/pages/inspector/ServerActions";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { fetchServerById, fetchUserById } from "@/lib/db";
|
import { fetchServerById, fetchUserById } from "@/lib/db";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { decodeTime } from "ulid";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export default async function Server({ params }: { params: { id: string } }) {
|
export default async function Server({ params }: { params: { id: string } }) {
|
||||||
const server = await fetchServerById(params.id);
|
const server = await fetchServerById(params.id);
|
||||||
|
@ -19,8 +25,9 @@ export default async function Server({ params }: { params: { id: string } }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<NavigationToolbar>Inspecting Server</NavigationToolbar>
|
<NavigationToolbar>Inspecting Server</NavigationToolbar>
|
||||||
<ServerCard server={server} subtitle="Server" />
|
<ServerCard server={server} subtitle={`Server · Created ${dayjs(decodeTime(server._id)).fromNow()}`} />
|
||||||
<ServerActions server={server} />
|
<ServerActions server={server} />
|
||||||
|
<SafetyNotesCard objectId={server._id} type="server" />
|
||||||
{server.description && (
|
{server.description && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
@ -29,9 +36,11 @@ export default async function Server({ params }: { params: { id: string } }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href={`/panel/inspect/user/${owner!._id}`}>
|
{
|
||||||
|
owner && <Link href={`/panel/inspect/user/${owner!._id}`}>
|
||||||
<UserCard user={owner!} subtitle="Server Owner" />
|
<UserCard user={owner!} subtitle="Server Owner" />
|
||||||
</Link>
|
</Link>
|
||||||
|
}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<RecentMessages query={{ channel: { $in: server.channels } }} users />
|
<RecentMessages query={{ channel: { $in: server.channels } }} users />
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { JsonCard } from "@/components/cards/JsonCard";
|
import { JsonCard } from "@/components/cards/JsonCard";
|
||||||
|
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||||
import { UserCard } from "@/components/cards/UserCard";
|
import { UserCard } from "@/components/cards/UserCard";
|
||||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||||
import { RecentMessages } from "@/components/pages/inspector/RecentMessages";
|
import { RecentMessages } from "@/components/pages/inspector/RecentMessages";
|
||||||
|
@ -23,9 +24,15 @@ import {
|
||||||
fetchUsersById,
|
fetchUsersById,
|
||||||
findDM,
|
findDM,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Bot } from "revolt-api";
|
import { Bot } from "revolt-api";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { decodeTime } from "ulid";
|
||||||
|
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export default async function User({
|
export default async function User({
|
||||||
params,
|
params,
|
||||||
|
@ -50,12 +57,10 @@ export default async function User({
|
||||||
const relevantUsers = await fetchUsersById([
|
const relevantUsers = await fetchUsersById([
|
||||||
...botIds,
|
...botIds,
|
||||||
...(
|
...(
|
||||||
user.relations?.filter((relation) => relation.status === "Friend") ?? []
|
user.relations ?? []
|
||||||
).map((relation) => relation._id),
|
).map((relation) => relation._id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
relevantUsers.sort((a) => (a.bot ? -1 : 0));
|
|
||||||
|
|
||||||
// Fetch server memberships
|
// Fetch server memberships
|
||||||
const serverMemberships = await fetchMembershipsByUser(user._id).catch(
|
const serverMemberships = await fetchMembershipsByUser(user._id).catch(
|
||||||
() => []
|
() => []
|
||||||
|
@ -74,8 +79,10 @@ export default async function User({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<NavigationToolbar>Inspecting User</NavigationToolbar>
|
<NavigationToolbar>Inspecting User</NavigationToolbar>
|
||||||
|
|
||||||
<UserCard user={user} subtitle={user.status?.text ?? "No status set"} />
|
<RestrictedUserCard id={user._id} />
|
||||||
|
<UserCard user={user} subtitle={`Joined ${dayjs(decodeTime(user._id)).fromNow()} · ${user.status?.text ?? "No status set"}`} />
|
||||||
<UserActions user={user} bot={bot as Bot} />
|
<UserActions user={user} bot={bot as Bot} />
|
||||||
|
<SafetyNotesCard objectId={user._id} type="user" />
|
||||||
|
|
||||||
{user.profile?.content && (
|
{user.profile?.content && (
|
||||||
<Card>
|
<Card>
|
||||||
|
@ -108,9 +115,6 @@ export default async function User({
|
||||||
<Separator />
|
<Separator />
|
||||||
<RelevantReports byUser={reportsByUser} forUser={reportsAgainstUser} />
|
<RelevantReports byUser={reportsByUser} forUser={reportsAgainstUser} />
|
||||||
|
|
||||||
<Separator />
|
|
||||||
<RecentMessages userId={user._id} />
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<JsonCard obj={user} />
|
<JsonCard obj={user} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { NavigationLinks } from "@/components/common/NavigationLinks";
|
import { NavigationLinks } from "@/components/common/NavigationLinks";
|
||||||
|
import { url as gravatarUrl } from 'gravatar';
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
|
||||||
export default function PanelLayout({
|
export default async function PanelLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const session = await getServerSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col h-[100vh]">
|
<main className="flex flex-col h-[100vh]">
|
||||||
<div className="p-4 flex justify-between items-center">
|
<div className="p-4 flex justify-between items-center">
|
||||||
|
@ -21,7 +25,20 @@ export default function PanelLayout({
|
||||||
<h1 className="text-3xl font-semibold">Admin Panel</h1>
|
<h1 className="text-3xl font-semibold">Admin Panel</h1>
|
||||||
</span>
|
</span>
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage src="/honse.png" />
|
<AvatarImage
|
||||||
|
src={
|
||||||
|
session?.user?.email
|
||||||
|
? gravatarUrl(
|
||||||
|
session.user.email,
|
||||||
|
{
|
||||||
|
size: '40',
|
||||||
|
default: 'https://admin.revolt.chat/honse.png',
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
: '/honse.png'
|
||||||
|
}
|
||||||
|
/>
|
||||||
<AvatarFallback>i</AvatarFallback>
|
<AvatarFallback>i</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <main>this is the admin panel ever</main>;
|
return <main>
|
||||||
|
<SafetyNotesCard objectId="home" type="global" title="Bulletin board" />
|
||||||
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,101 @@
|
||||||
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 { Report } from "revolt-api";
|
||||||
|
|
||||||
export default async function Reports() {
|
export default async function Reports() {
|
||||||
const reports = (await fetchOpenReports())
|
const reports = (await fetchOpenReports())
|
||||||
.reverse()
|
.reverse()
|
||||||
.sort((b, _) => (b.content.report_reason.includes("Illegal") ? -1 : 0));
|
.sort((b, _) => (b.content.report_reason.includes("Illegal") ? -1 : 0));
|
||||||
|
|
||||||
|
const byCategory: Record<string, Report[]> = {
|
||||||
|
Urgent: [],
|
||||||
|
All: [],
|
||||||
|
AssignedToCase: [],
|
||||||
|
};
|
||||||
|
const keyOrder = ["Urgent", "All"];
|
||||||
|
|
||||||
|
const countsByAuthor: Record<string, number> = {};
|
||||||
|
for (const report of reports) {
|
||||||
|
if (report.case_id) {
|
||||||
|
byCategory.AssignedToCase.push(report);
|
||||||
|
} else if (report.content.report_reason.includes("Illegal")) {
|
||||||
|
byCategory.Urgent.push(report);
|
||||||
|
} else {
|
||||||
|
countsByAuthor[report.author_id] =
|
||||||
|
(countsByAuthor[report.author_id] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const report of reports) {
|
||||||
|
if (report.case_id) continue;
|
||||||
|
|
||||||
|
if (!report.content.report_reason.includes("Illegal")) {
|
||||||
|
if (countsByAuthor[report.author_id] > 1) {
|
||||||
|
if (!keyOrder.includes(report.author_id)) {
|
||||||
|
keyOrder.push(report.author_id);
|
||||||
|
byCategory[report.author_id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
byCategory[report.author_id].push(report);
|
||||||
|
} else {
|
||||||
|
byCategory.All.push(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/*<Input placeholder="Search for reports..." disabled />*/}
|
||||||
|
{reports.length ? (
|
||||||
|
keyOrder
|
||||||
|
.filter((key) => byCategory[key].length)
|
||||||
|
.map((key) => {
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-2xl">{authorNames[key] ?? key}</h1>
|
||||||
|
{byCategory[key].map((report) => (
|
||||||
|
<CardLink
|
||||||
|
key={report._id}
|
||||||
|
href={`/panel/reports/${report._id}`}
|
||||||
|
>
|
||||||
|
<ReportCard report={report} />
|
||||||
|
</CardLink>
|
||||||
|
))}{" "}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-8 flex justify-center">
|
||||||
|
<PizzaIcon className="text-gray-400" />
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-xs text-center pb-2 text-gray-400">
|
||||||
|
You‘ve caught up for now.
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{byCategory["AssignedToCase"].length && (
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<h1 className="text-xl inline">Reports assigned to cases</h1>
|
||||||
|
</summary>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Input placeholder="Search for reports..." disabled />
|
{byCategory["AssignedToCase"].map((report) => (
|
||||||
{reports.map((report) => (
|
|
||||||
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
||||||
<ReportCard report={report} />
|
<ReportCard report={report} />
|
||||||
</CardLink>
|
</CardLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import EmailClassificationRow, {
|
||||||
|
CLASSIFICATIONS,
|
||||||
|
} from "@/components/pages/shield/EmailClassificationRow";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Command, CommandItem } from "@/components/ui/command";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
createEmailClassification,
|
||||||
|
fetchEmailClassifications,
|
||||||
|
} from "@/lib/actions";
|
||||||
|
import { EmailClassification } from "@/lib/db";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function Classifications() {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [domains, setDomains] = useState([] as EmailClassification[]);
|
||||||
|
const [domainDraft, setDomainDraft] = useState("");
|
||||||
|
const [classificationDraft, setClassificationDraft] = useState<string>("");
|
||||||
|
const [classificationOpen, setClassificationOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmailClassifications().then((domains) => {
|
||||||
|
setDomains(domains.sort((a, b) => a._id.localeCompare(b._id)));
|
||||||
|
setLoaded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Domain</TableHead>
|
||||||
|
<TableHead>Classification</TableHead>
|
||||||
|
<TableHead className="flex flex-row items-center justify-between gap-2 pr-1">
|
||||||
|
<span>Action</span>
|
||||||
|
<div className="text-end pr-2">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={!loaded}>Add</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Create classification</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="flex flex-row gap-1">
|
||||||
|
<Input
|
||||||
|
value={domainDraft}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDomainDraft(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
placeholder="reddit.com"
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
open={classificationOpen}
|
||||||
|
onOpenChange={setClassificationOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={classificationOpen}
|
||||||
|
>
|
||||||
|
{classificationDraft || "Classification"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<Command>
|
||||||
|
{CLASSIFICATIONS.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c}
|
||||||
|
onSelect={() => {
|
||||||
|
setClassificationDraft(c);
|
||||||
|
setClassificationOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!domainDraft || !classificationDraft}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await createEmailClassification(
|
||||||
|
domainDraft,
|
||||||
|
classificationDraft
|
||||||
|
);
|
||||||
|
setDomains([
|
||||||
|
...domains,
|
||||||
|
{
|
||||||
|
_id: domainDraft,
|
||||||
|
classification: classificationDraft,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setDomainDraft("");
|
||||||
|
setClassificationDraft("");
|
||||||
|
setClassificationOpen(false);
|
||||||
|
toast({
|
||||||
|
title: "Classification created",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to create classification",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<EmailClassificationRow
|
||||||
|
key={domain._id}
|
||||||
|
domain={domain._id}
|
||||||
|
classification={domain.classification}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Shield() {
|
||||||
|
// todo add a list of buttons here once there's more categories
|
||||||
|
redirect("/panel/shield/classifications");
|
||||||
|
// return (
|
||||||
|
// <div className="flex flex-row gap-2">
|
||||||
|
// <Link href="/panel/shield/classifications"><Button>Email Classifications</Button></Link>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,25 @@
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
export default function Sparkle() {
|
export default function Sparkle() {
|
||||||
|
const hash = execSync("git rev-parse HEAD", { cwd: process.cwd() }).toString().trim();
|
||||||
|
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: process.cwd() }).toString().trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl text-center">Running version v0.0.1</h1>
|
<h1 className="text-xl text-center">
|
||||||
|
<span>Running version </span>
|
||||||
|
<code>
|
||||||
|
<a href={`https://git.revolt.chat/administration/panel/commit/${hash}`} target="_blank">
|
||||||
|
{hash.substring(0, 10)}
|
||||||
|
</a>
|
||||||
|
</code>
|
||||||
|
<span> on branch </span>
|
||||||
|
<code>
|
||||||
|
<a href={`https://git.revolt.chat/administration/panel/src/branch/${branch}`} target="_blank">
|
||||||
|
{branch}
|
||||||
|
</a>
|
||||||
|
</code>
|
||||||
|
</h1>
|
||||||
<img
|
<img
|
||||||
className="absolute right-0 bottom-0"
|
className="absolute right-0 bottom-0"
|
||||||
src="https://api.gifbox.me/file/posts/MF3oORlDjfHAVJ-DgPyRQSjMdy9WNIxk.webp"
|
src="https://api.gifbox.me/file/posts/MF3oORlDjfHAVJ-DgPyRQSjMdy9WNIxk.webp"
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -19,7 +19,7 @@ export function ChannelCard({
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle className="flex items-center gap-1">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
{channel.channel_type !== "DirectMessage" && (
|
{channel.channel_type !== "DirectMessage" && (
|
||||||
<AvatarImage src={`${AUTUMN_URL}/icons/${channel.icon?._id}`} />
|
<AvatarImage src={`${AUTUMN_URL}/icons/${channel.icon?._id}`} />
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { ChannelInvite } from "@/lib/db";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Channel, User } from "revolt-api";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { AlertDialogFooter, AlertDialogHeader, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, AlertDialogTrigger } from "../ui/alert-dialog";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { deleteInvite, editInvite, editInviteChannel } from "@/lib/actions";
|
||||||
|
import { ChannelDropdown } from "../pages/inspector/InviteList";
|
||||||
|
|
||||||
|
export function InviteCard({
|
||||||
|
invite,
|
||||||
|
channel: channelInput,
|
||||||
|
channelList,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
invite: ChannelInvite;
|
||||||
|
channel?: Channel;
|
||||||
|
channelList?: Channel[];
|
||||||
|
user?: User;
|
||||||
|
}) {
|
||||||
|
const [editDraft, setEditDraft] = useState(invite._id);
|
||||||
|
const [deleted, setDeleted] = useState(false);
|
||||||
|
const [code, setCode] = useState(invite._id);
|
||||||
|
const [channelId, setChannelId] = useState(channelInput?._id ?? "");
|
||||||
|
const [channelDraft, setChannelDraft] = useState(channelInput?._id ?? "");
|
||||||
|
|
||||||
|
const channel = useMemo(
|
||||||
|
() => channelList?.find(channel => channel._id == channelId) || channelInput,
|
||||||
|
[channelId, channelList, channelInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="my-2 flex">
|
||||||
|
<CardHeader className="flex-1">
|
||||||
|
<CardTitle className="flex items-center">
|
||||||
|
<span className="font-extralight mr-0.5 select-none">rvlt.gg/</span>{code}
|
||||||
|
<span className="select-none">{" "}</span> {/* looks better like this when for some reason the css doesnt load */}
|
||||||
|
{invite.vanity
|
||||||
|
? <span
|
||||||
|
className="select-none ml-2 p-1.5 bg-gray-400 text-white rounded-md font-normal text-base"
|
||||||
|
>
|
||||||
|
Vanity
|
||||||
|
</span>
|
||||||
|
: <></>}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{invite.type}
|
||||||
|
{" • "}
|
||||||
|
<Link href={`/panel/inspect/channel/${invite.channel}`}>
|
||||||
|
{(
|
||||||
|
channel &&
|
||||||
|
channel.channel_type != "DirectMessage" &&
|
||||||
|
channel.channel_type != "SavedMessages"
|
||||||
|
)
|
||||||
|
? `#${channel.name}`
|
||||||
|
: <i>Unknown Channel</i>}
|
||||||
|
</Link>
|
||||||
|
{" • "}
|
||||||
|
<Link href={`/panel/inspect/user/${invite.creator}`}>
|
||||||
|
{user ? `${user.username}#${user.discriminator}` : <i>Unknown Creator</i>}
|
||||||
|
</Link>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center py-0 gap-2">
|
||||||
|
{invite.vanity
|
||||||
|
? (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button>Edit</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Edit vanity invite
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<p className="mb-2">Invites are case sensitive.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={editDraft}
|
||||||
|
onChange={(e) => setEditDraft(e.currentTarget.value)}
|
||||||
|
placeholder={code}
|
||||||
|
/>
|
||||||
|
<ChannelDropdown
|
||||||
|
channels={channelList || []}
|
||||||
|
value={channelDraft}
|
||||||
|
setValue={setChannelDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!editDraft}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (code != editDraft) await editInvite(code, editDraft);
|
||||||
|
if (channel?._id != channelDraft) await editInviteChannel(editDraft, channelDraft);
|
||||||
|
setCode(editDraft);
|
||||||
|
setEditDraft("");
|
||||||
|
setChannelId(channelDraft);
|
||||||
|
toast({ title: "Invite edited" });
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to edit invite",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Edit</AlertDialogAction>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
: <></>}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button>Delete</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete invite
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to irreversibly delete this invite?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteInvite(code);
|
||||||
|
setDeleted(true);
|
||||||
|
toast({ title: "Invite deleted" });
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to delete invite",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Delete</AlertDialogAction>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,13 +1,23 @@
|
||||||
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);
|
||||||
|
|
||||||
export function ReportCard({ report }: { report: Report }) {
|
const lastWeek = new Date();
|
||||||
|
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||||
|
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
export function ReportCard({ report }: { report: ReportDocument }) {
|
||||||
|
const dueDate = +(report.content.report_reason.includes("Illegal")
|
||||||
|
? yesterday
|
||||||
|
: lastWeek);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
@ -37,6 +47,25 @@ export function ReportCard({ report }: { report: Report }) {
|
||||||
{dayjs(decodeTime(report._id)).fromNow()}{" "}
|
{dayjs(decodeTime(report._id)).fromNow()}{" "}
|
||||||
{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 && (
|
||||||
|
<>
|
||||||
|
·{" "}
|
||||||
|
<Badge className="align-middle" variant="relatively-destructive">
|
||||||
|
Due{" "}
|
||||||
|
{dayjs()
|
||||||
|
.add(dayjs(decodeTime(report._id)).diff(dueDate))
|
||||||
|
.fromNow()}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { RESTRICT_ACCESS_LIST } from "@/lib/constants";
|
||||||
|
import { User } from "revolt-api";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export function RestrictedUserCard({ id }: { id: string | null | undefined }) {
|
||||||
|
if (!id || !RESTRICT_ACCESS_LIST.includes(id)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="p-2 bg-red-500 text-white flex flex-row gap-2"
|
||||||
|
>
|
||||||
|
<AlertCircle />
|
||||||
|
Destructive actions are disabled for this user
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { SafetyNotes, fetchSafetyNote, updateSafetyNote } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../ui/card";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
export default function SafetyNotesCard({
|
||||||
|
objectId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
objectId: string;
|
||||||
|
type: SafetyNotes["_id"]["type"];
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const session = useSession();
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [value, setValue] = useState<SafetyNotes | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSafetyNote(objectId, type)
|
||||||
|
.then((note) => {
|
||||||
|
setDraft(note?.text || "");
|
||||||
|
setValue(note);
|
||||||
|
setReady(true);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError(String(e));
|
||||||
|
});
|
||||||
|
}, [objectId, type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{editing ? (
|
||||||
|
<Textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder={
|
||||||
|
error
|
||||||
|
? error
|
||||||
|
: ready
|
||||||
|
? "Enter notes here... (save on unfocus)"
|
||||||
|
: "Fetching notes..."
|
||||||
|
}
|
||||||
|
className="!min-h-[80px] max-h-[50vh]"
|
||||||
|
disabled={!ready || error != null}
|
||||||
|
value={ready ? draft : undefined} // not defaulting to undefined causes next to complain
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
|
onBlur={async () => {
|
||||||
|
if (draft === value?.text ?? "") return setEditing(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSafetyNote(objectId, type, draft);
|
||||||
|
setValue({
|
||||||
|
_id: { id: objectId, type: type },
|
||||||
|
edited_at: new Date(Date.now()),
|
||||||
|
edited_by: session.data?.user?.email || "",
|
||||||
|
text: draft,
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
toast({
|
||||||
|
title: "Updated notes",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setEditing(false);
|
||||||
|
toast({
|
||||||
|
title: "Failed to update notes",
|
||||||
|
description: String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div onClick={() => setEditing(true)}>
|
||||||
|
{error ? (
|
||||||
|
<>{error}</>
|
||||||
|
) : value?.text ? (
|
||||||
|
<ReactMarkdown
|
||||||
|
className="prose prose-a:text-[#fd6671] prose-img:max-h-96 max-w-none"
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
>
|
||||||
|
{value.text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : ready ? (
|
||||||
|
<i>Click to add a note</i>
|
||||||
|
) : (
|
||||||
|
<i>Fetching notes...</i>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="-my-2">
|
||||||
|
<CardDescription>
|
||||||
|
{value ? (
|
||||||
|
<>
|
||||||
|
Last edited {dayjs(value.edited_at).fromNow()} by{" "}
|
||||||
|
{value.edited_by}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>No object note set</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ export function ServerCard({
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle className="flex items-center gap-1">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage src={`${AUTUMN_URL}/icons/${server.icon?._id}`} />
|
<AvatarImage src={`${AUTUMN_URL}/icons/${server.icon?._id}`} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
|
|
|
@ -3,20 +3,29 @@ import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
import { AUTUMN_URL } from "@/lib/constants";
|
import { AUTUMN_URL } from "@/lib/constants";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
|
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)`;
|
||||||
|
|
||||||
export function UserCard({ user, subtitle }: { user: User; subtitle: string }) {
|
|
||||||
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%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="overflow-hidden overflow-ellipsis whitespace-nowrap">
|
<CardTitle className="overflow-hidden overflow-ellipsis whitespace-nowrap flex items-center gap-1">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar?._id}`} />
|
<AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar?._id}`} />
|
||||||
<AvatarFallback className="overflow-hidden overflow-ellipsis whitespace-nowrap">
|
<AvatarFallback className="overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||||
|
@ -28,7 +37,18 @@ export function UserCard({ user, subtitle }: { user: User; subtitle: string }) {
|
||||||
</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">
|
||||||
{user.username}#{user.discriminator} {user.display_name}
|
{user.username}#{user.discriminator} {user.display_name}
|
||||||
|
{
|
||||||
|
withLink
|
||||||
|
? <Link href={`/panel/inspect/user/${user._id}`}>
|
||||||
|
<ExternalLinkIcon className="text-gray-500 hover:text-gray-700 transition-all" />
|
||||||
|
</Link>
|
||||||
|
: <></>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{subtitle}</CardDescription>
|
<CardDescription>{subtitle}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,14 +1,17 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { buttonVariants } from "../ui/button";
|
import { buttonVariants } from "../ui/button";
|
||||||
import {
|
import {
|
||||||
|
Bomb,
|
||||||
Eye,
|
Eye,
|
||||||
Globe2,
|
Globe2,
|
||||||
Home,
|
Home,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
|
Shield,
|
||||||
Siren,
|
Siren,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
BookCopy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export function NavigationLinks() {
|
export function NavigationLinks() {
|
||||||
|
@ -32,12 +35,30 @@ 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"
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||||
|
href="/panel/shield"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||||
|
href="/panel/backups"
|
||||||
|
>
|
||||||
|
<Bomb className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
{/*<Link
|
{/*<Link
|
||||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||||
href="/panel/discover"
|
href="/panel/discover"
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -23,7 +23,7 @@ export function LoginButton() {
|
||||||
</Button>
|
</Button>
|
||||||
<img
|
<img
|
||||||
src={`https://api.gifbox.me/file/posts/aYON6GqiqpwSpiZmAbJoOtw8tM2uYsEU.webp`}
|
src={`https://api.gifbox.me/file/posts/aYON6GqiqpwSpiZmAbJoOtw8tM2uYsEU.webp`}
|
||||||
height={320}
|
className="h-[320px]"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -43,7 +43,7 @@ export function LoginButton() {
|
||||||
</Button>
|
</Button>
|
||||||
<img
|
<img
|
||||||
src={`https://api.gifbox.me/file/posts/w7iUJfiyKA_zGkHN7Rr625WpaTHYgm4v.webp`}
|
src={`https://api.gifbox.me/file/posts/w7iUJfiyKA_zGkHN7Rr625WpaTHYgm4v.webp`}
|
||||||
height={320}
|
className="h-[320px]"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,7 +8,9 @@ import { User } from "revolt-api";
|
||||||
import {
|
import {
|
||||||
cancelAccountDeletion,
|
cancelAccountDeletion,
|
||||||
changeAccountEmail,
|
changeAccountEmail,
|
||||||
|
deleteMFARecoveryCodes,
|
||||||
disableAccount,
|
disableAccount,
|
||||||
|
disableMFA,
|
||||||
queueAccountDeletion,
|
queueAccountDeletion,
|
||||||
restoreAccount,
|
restoreAccount,
|
||||||
verifyAccountEmail,
|
verifyAccountEmail,
|
||||||
|
@ -43,7 +45,7 @@ export function AccountActions({
|
||||||
const [emailDraft, setEmailDraft] = useState("");
|
const [emailDraft, setEmailDraft] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button className="flex-1">
|
<Button className="flex-1">
|
||||||
|
@ -138,6 +140,77 @@ export function AccountActions({
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!accountDraft.mfa?.totp_token?.status}
|
||||||
|
>
|
||||||
|
MFA {accountDraft.mfa?.totp_token?.status.toLowerCase() || "disabled"}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Manage MFA
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
MFA is currently {
|
||||||
|
accountDraft.mfa?.totp_token?.status == "Pending"
|
||||||
|
? "pending setup"
|
||||||
|
: (accountDraft.mfa?.totp_token?.status.toLowerCase() || "disabled")
|
||||||
|
}.
|
||||||
|
<br />
|
||||||
|
The account has {accountDraft.mfa?.recovery_codes ?? "no"} recovery codes.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="hover:bg-red-800"
|
||||||
|
disabled={accountDraft.mfa?.recovery_codes == null}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteMFARecoveryCodes(account._id);
|
||||||
|
toast({
|
||||||
|
title: "MFA recovery codes cleared",
|
||||||
|
});
|
||||||
|
accountDraft.mfa!.recovery_codes = undefined;
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to clear recovery codes",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear recovery codes
|
||||||
|
</AlertDialogAction>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="hover:bg-red-800"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await disableMFA(account._id);
|
||||||
|
toast({
|
||||||
|
title: "MFA disabled",
|
||||||
|
});
|
||||||
|
accountDraft.mfa!.totp_token = undefined;
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: " Failed to disable MFA",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disable MFA
|
||||||
|
</AlertDialogAction>
|
||||||
|
<AlertDialogCancel>Close</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,286 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { InviteCard } from "@/components/cards/InviteCard";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Command, CommandItem } from "@/components/ui/command";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { bulkDeleteInvites, createInvite } from "@/lib/actions";
|
||||||
|
import { ChannelInvite } from "@/lib/db";
|
||||||
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Channel, Server, User } from "revolt-api";
|
||||||
|
|
||||||
|
export default function ServerInviteList({ server, invites: invitesInput, channels, users }: {
|
||||||
|
server: Server,
|
||||||
|
invites: ChannelInvite[],
|
||||||
|
channels?: Channel[],
|
||||||
|
users?: User[],
|
||||||
|
}) {
|
||||||
|
const [invites, setInvites] = useState(invitesInput);
|
||||||
|
const [selectVanityOnly, setSelectVanityOnly] = useState(false);
|
||||||
|
const [selectChannel, setSelectChannel] = useState(false);
|
||||||
|
const [selectUser, setSelectUser] = useState(false);
|
||||||
|
const [vanityFilter, setVanityFilter] = useState<boolean | null>(null);
|
||||||
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
|
const [userFilter, setUserFilter] = useState("");
|
||||||
|
const [inviteDraft, setInviteDraft] = useState("");
|
||||||
|
const [inviteChannelDraft, setInviteChannelDraft] = useState("");
|
||||||
|
|
||||||
|
const filteredInvites = useMemo(() => {
|
||||||
|
return invites
|
||||||
|
?.filter(invite => vanityFilter === true ? invite.vanity : vanityFilter === false ? !invite.vanity : true)
|
||||||
|
?.filter(invite => channelFilter ? invite.channel == channelFilter : true)
|
||||||
|
?.filter(invite => userFilter ? invite.creator == userFilter : true)
|
||||||
|
?.reverse();
|
||||||
|
}, [vanityFilter, channelFilter, userFilter, invites]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Popover open={selectVanityOnly} onOpenChange={setSelectVanityOnly}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={selectVanityOnly}
|
||||||
|
className="flex-1 justify-between"
|
||||||
|
>
|
||||||
|
{vanityFilter === true ? "Vanity" : vanityFilter === false ? "Not vanity" : "All"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
{[
|
||||||
|
{ value: null, label: "All" },
|
||||||
|
{ value: true, label: "Vanity" },
|
||||||
|
{ value: false, label: "Not vanity" },
|
||||||
|
].map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={String(option.value)}
|
||||||
|
onSelect={async () => {
|
||||||
|
setSelectVanityOnly(false);
|
||||||
|
setVanityFilter(option.value);
|
||||||
|
}}
|
||||||
|
>{option.label}</CommandItem>
|
||||||
|
))}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover open={selectChannel} onOpenChange={setSelectChannel}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={selectChannel}
|
||||||
|
className="flex-1 justify-between"
|
||||||
|
>
|
||||||
|
{channelFilter
|
||||||
|
? '#' + (channels?.find(c => c._id == channelFilter) as any)?.name
|
||||||
|
: "Select channel"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandItem onSelect={async () => {
|
||||||
|
setSelectChannel(false);
|
||||||
|
setChannelFilter("");
|
||||||
|
}}
|
||||||
|
>All channels</CommandItem>
|
||||||
|
{channels?.map((channel) => (
|
||||||
|
<CommandItem
|
||||||
|
key={String(channel._id)}
|
||||||
|
onSelect={async () => {
|
||||||
|
setSelectChannel(false);
|
||||||
|
setChannelFilter(channel._id);
|
||||||
|
}}
|
||||||
|
>{'#' + (channel as any).name}</CommandItem>
|
||||||
|
))}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover open={selectUser} onOpenChange={setSelectUser}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={selectUser}
|
||||||
|
className="flex-1 justify-between"
|
||||||
|
>
|
||||||
|
{userFilter
|
||||||
|
? `${users?.find(c => c._id == userFilter)?.username}#${users?.find(c => c._id == userFilter)?.discriminator}`
|
||||||
|
: "Select user"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandItem onSelect={async () => {
|
||||||
|
setSelectUser(false);
|
||||||
|
setUserFilter("");
|
||||||
|
}}
|
||||||
|
>All users</CommandItem>
|
||||||
|
{users?.map((user) => (
|
||||||
|
<CommandItem
|
||||||
|
key={String(user._id)}
|
||||||
|
onSelect={async () => {
|
||||||
|
setSelectUser(false);
|
||||||
|
setUserFilter(user._id);
|
||||||
|
}}
|
||||||
|
>{user.username}#{user.discriminator}</CommandItem>
|
||||||
|
))}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={!filteredInvites.length} variant="destructive">Bulk delete</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Bulk delete invites
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete all invites that match your filter options.
|
||||||
|
<br />
|
||||||
|
<b>{filteredInvites.length}</b> invite{filteredInvites.length == 1 ? '' : 's'} will be deleted.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await bulkDeleteInvites(filteredInvites.map(i => i._id));
|
||||||
|
setInvites(invites.filter(invite => !filteredInvites.find(i => i._id == invite._id)));
|
||||||
|
toast({ title: "Selected invites have been deleted" });
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to delete invites",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Bulk delete</AlertDialogAction>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={!filteredInvites.length}>Create vanity invite</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Create vanity invite
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<p className="mb-2">Invites are case sensitive.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={inviteDraft}
|
||||||
|
onChange={(e) => setInviteDraft(e.currentTarget.value)}
|
||||||
|
placeholder="fortnite"
|
||||||
|
/>
|
||||||
|
<ChannelDropdown
|
||||||
|
channels={channels ?? []}
|
||||||
|
value={inviteChannelDraft}
|
||||||
|
setValue={setInviteChannelDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!inviteDraft || !inviteChannelDraft}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const newInvite: ChannelInvite = {
|
||||||
|
_id: inviteDraft,
|
||||||
|
channel: inviteChannelDraft,
|
||||||
|
creator: server.owner,
|
||||||
|
server: server._id,
|
||||||
|
type: "Server",
|
||||||
|
vanity: true,
|
||||||
|
};
|
||||||
|
await createInvite(newInvite);
|
||||||
|
setInvites([...invites, newInvite]);
|
||||||
|
setInviteDraft("");
|
||||||
|
toast({
|
||||||
|
title: "Vanity invite created",
|
||||||
|
description: <a href={`https://rvlt.gg/${inviteDraft}`}>rvlt.gg/{inviteDraft}</a>
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to create invite",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Create</AlertDialogAction>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredInvites.map(invite => (<InviteCard
|
||||||
|
invite={invite}
|
||||||
|
channel={channels?.find(c => c._id == invite.channel)}
|
||||||
|
channelList={channels}
|
||||||
|
user={users?.find(c => c._id == invite.creator)}
|
||||||
|
key={invite._id}
|
||||||
|
/>))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelDropdown({ channels, value, setValue }: {
|
||||||
|
channels: Channel[],
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => any,
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={expanded} onOpenChange={setExpanded}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="flex-1 justify-between"
|
||||||
|
>
|
||||||
|
{value
|
||||||
|
? '#' + (channels?.find(c => c._id == value) as any)?.name
|
||||||
|
: "Channel"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
{channels?.map((channel) => (
|
||||||
|
<CommandItem
|
||||||
|
key={String(channel._id)}
|
||||||
|
onSelect={async () => {
|
||||||
|
setExpanded(false);
|
||||||
|
setValue(channel._id);
|
||||||
|
}}
|
||||||
|
>{'#' + (channel as any).name}</CommandItem>
|
||||||
|
))}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
|
@ -18,12 +18,24 @@ export function RelevantObjects({
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
<h2 className="text-md text-center pb-2">Bots & Friends</h2>
|
<h2 className="text-md text-center pb-2">Bots & Relations</h2>
|
||||||
<ListCompactor
|
<ListCompactor
|
||||||
data={users}
|
data={[
|
||||||
|
// for whatever fucking reason nextjs threw a bunch of errors at me
|
||||||
|
// when i used .sort() here but i guess this works well enough..?
|
||||||
|
...users.filter((user) => user.bot?.owner == userId),
|
||||||
|
...users.filter((user) => user.bot?.owner != userId),
|
||||||
|
]}
|
||||||
Component={({ item }) => (
|
Component={({ item }) => (
|
||||||
<Link href={`/panel/inspect/user/${item._id}`}>
|
<Link href={`/panel/inspect/user/${item._id}`}>
|
||||||
<UserCard user={item} subtitle="" />
|
<UserCard
|
||||||
|
user={item}
|
||||||
|
subtitle={
|
||||||
|
item.bot?.owner == userId
|
||||||
|
? "Owned bot"
|
||||||
|
: item.relations?.find((relation) => relation._id == userId)?.status || ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -31,12 +43,21 @@ export function RelevantObjects({
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
<h2 className="text-md text-center pb-2">Servers</h2>
|
<h2 className="text-md text-center pb-2">Servers</h2>
|
||||||
<ListCompactor
|
<ListCompactor
|
||||||
data={servers}
|
// same as above
|
||||||
|
data={[
|
||||||
|
...servers.filter((server) => userId == server.owner),
|
||||||
|
...servers.filter((server) => userId != server.owner),
|
||||||
|
]}
|
||||||
Component={({ item }) => (
|
Component={({ item }) => (
|
||||||
<Link href={`/panel/inspect/server/${item._id}`}>
|
<Link href={`/panel/inspect/server/${item._id}`}>
|
||||||
<ServerCard
|
<ServerCard
|
||||||
server={item}
|
server={item}
|
||||||
subtitle={userId === item.owner ? "Server Owner" : ""}
|
subtitle={
|
||||||
|
[
|
||||||
|
userId === item.owner ? "Server Owner" : null,
|
||||||
|
item.discoverable ? "Discoverable" : null,
|
||||||
|
].filter(i => i).join(" · ")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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" ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Server } from "revolt-api";
|
import { Server, User } from "revolt-api";
|
||||||
import { Button } from "../../ui/button";
|
import { Button, buttonVariants } from "../../ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
CommandItem,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import {
|
import {
|
||||||
|
@ -17,16 +14,28 @@ import {
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { updateServerDiscoverability, updateServerFlags } from "@/lib/actions";
|
import { addServerMember, quarantineServer, updateServerDiscoverability, updateServerFlags, updateServerOwner } from "@/lib/actions";
|
||||||
import { useToast } from "../../ui/use-toast";
|
import { useToast } from "../../ui/use-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
|
||||||
|
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import UserSelector from "@/components/ui/user-selector";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { SEVRER_REMOVAL_MESSAGE } from "@/lib/constants";
|
||||||
|
|
||||||
export function ServerActions({ server }: { server: Server }) {
|
export function ServerActions({ server }: { server: Server }) {
|
||||||
const [selectBadges, setSelectBadges] = useState(false);
|
const [selectBadges, setSelectBadges] = useState(false);
|
||||||
const [serverDraft, setDraft] = useState(server);
|
const [serverDraft, setDraft] = useState(server);
|
||||||
|
const [quarantineMessage, setQuarantineMessage] = useState(SEVRER_REMOVAL_MESSAGE(server));
|
||||||
|
const [newOwner, setNewOwner] = useState<User | null>(null);
|
||||||
|
const [newMember, setNewMember] = useState<User | null>(null);
|
||||||
|
const [newMemberEvent, setNewMemberEvent] = useState(true);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
{serverDraft.discoverable ? (
|
{serverDraft.discoverable ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
@ -130,9 +139,163 @@ export function ServerActions({ server }: { server: Server }) {
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className={`flex-1 ${buttonVariants()}`}
|
||||||
|
href={`/panel/inspect/server/${server._id}/invites`}
|
||||||
|
>
|
||||||
|
Invites
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
<Button className="flex-1" variant="destructive">
|
<Button className="flex-1" variant="destructive">
|
||||||
Quarantine
|
Quarantine
|
||||||
</Button>
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Quarantine server
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="flex flex-col gap-1">
|
||||||
|
<span>This will remove all members from this server and revoke all invites.</span>
|
||||||
|
<span className="text-red-700">This action is irreversible!</span>
|
||||||
|
<br />
|
||||||
|
<Textarea
|
||||||
|
placeholder="Removal message"
|
||||||
|
value={quarantineMessage}
|
||||||
|
onChange={(e) => setQuarantineMessage(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-800 hover:bg-red-700"
|
||||||
|
disabled={quarantineMessage == SEVRER_REMOVAL_MESSAGE(server) || !quarantineMessage}
|
||||||
|
onClick={async () => {
|
||||||
|
if (serverDraft.flags) {
|
||||||
|
// Intentionally not clearing the quarantine message draft
|
||||||
|
toast({
|
||||||
|
title: "Refusing to quarantine",
|
||||||
|
description: "This server is marked as verified or official",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quarantineServer(server._id, quarantineMessage);
|
||||||
|
toast({
|
||||||
|
title: "Quarantined server",
|
||||||
|
});
|
||||||
|
setQuarantineMessage(SEVRER_REMOVAL_MESSAGE(server));
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to quarantine",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quarantine
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="flex-1">
|
||||||
|
More Options
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="flex flex-col">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost">
|
||||||
|
Change owner
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Change server owner
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="flex flex-col gap-2">
|
||||||
|
Enter the ID of the new server owner.
|
||||||
|
<UserSelector
|
||||||
|
onChange={(user) => setNewOwner(user)}
|
||||||
|
/>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!newOwner}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await updateServerOwner(server._id, newOwner!._id);
|
||||||
|
setNewOwner(null);
|
||||||
|
toast({ title: "Server owner changed" });
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Owner update failed",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost">
|
||||||
|
Add member
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Add member to server
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="flex flex-col gap-2">
|
||||||
|
Enter the ID of the user you want to add.
|
||||||
|
<UserSelector onChange={(user) => setNewMember(user)} />
|
||||||
|
<Checkbox checked={newMemberEvent} onChange={(state) => setNewMemberEvent(state === true)}>Publish join event</Checkbox>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!newMember}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await addServerMember(server._id, newMember!._id, newMemberEvent);
|
||||||
|
setNewMember(null);
|
||||||
|
toast({ title: "User added to server" });
|
||||||
|
} catch(e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to add user",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,11 +23,14 @@ import { Input } from "../../ui/input";
|
||||||
import {
|
import {
|
||||||
banUser,
|
banUser,
|
||||||
closeReportsByUser,
|
closeReportsByUser,
|
||||||
|
resetBotToken,
|
||||||
sendAlert,
|
sendAlert,
|
||||||
suspendUser,
|
suspendUser,
|
||||||
|
transferBot,
|
||||||
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";
|
||||||
|
@ -37,6 +40,8 @@ import { Card, CardHeader } from "../../ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { decodeTime } from "ulid";
|
import { decodeTime } from "ulid";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import UserSelector from "@/components/ui/user-selector";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
const badges = [1, 2, 4, 8, 16, 32, 128, 0, 256, 512, 1024];
|
const badges = [1, 2, 4, 8, 16, 32, 128, 0, 256, 512, 1024];
|
||||||
|
|
||||||
|
@ -53,6 +58,8 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
displayName: false,
|
displayName: false,
|
||||||
status: false,
|
status: false,
|
||||||
});
|
});
|
||||||
|
const [transferTarget, setTransferTarget] = useState<User | null>(null);
|
||||||
|
const [transferResetToken, setTransferResetToken] = useState(true);
|
||||||
|
|
||||||
const userInaccessible = userDraft.flags === 4 || userDraft.flags === 2;
|
const userInaccessible = userDraft.flags === 4 || userDraft.flags === 2;
|
||||||
|
|
||||||
|
@ -103,7 +110,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
{bot ? (
|
{bot ? (
|
||||||
botDraft!.discoverable ? (
|
botDraft!.discoverable ? (
|
||||||
<Button
|
<Button
|
||||||
|
@ -204,7 +211,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Suspend
|
{userDraft.flags === 1 ? "Unsuspend" : "Suspend"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
@ -225,13 +232,18 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
Are you sure you want to ban this user?
|
Are you sure you want to ban this user?
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-red-700">
|
<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!
|
This action is irreversible!
|
||||||
|
</span>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
|
className="hover:bg-red-700 transition-all"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
banUser(user._id)
|
banUser(user._id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@ -253,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>
|
||||||
|
@ -292,7 +349,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
This will send a message from the Platform Moderation
|
This will send a message from the Platform Moderation
|
||||||
account.
|
account.
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Textarea
|
||||||
placeholder="Enter a message..."
|
placeholder="Enter a message..."
|
||||||
name="message"
|
name="message"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
@ -411,6 +468,101 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" disabled={!user.bot?.owner}>
|
||||||
|
Reset bot token
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reset token</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
Re-roll this bot's authentication token. This will
|
||||||
|
not disconnect active connections.
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() =>
|
||||||
|
resetBotToken(user._id)
|
||||||
|
.then(() =>
|
||||||
|
toast({
|
||||||
|
title: "Reset bot token",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
toast({
|
||||||
|
title: "Failed to reset token",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" disabled={!user.bot?.owner}>
|
||||||
|
Transfer bot
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Transfer bot</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="flex flex-col gap-2">
|
||||||
|
<span>Transfer this bot to a new owner.</span>
|
||||||
|
<UserSelector onChange={setTransferTarget} />
|
||||||
|
<Checkbox
|
||||||
|
checked={transferResetToken}
|
||||||
|
onChange={(e) => setTransferResetToken(!!e)}
|
||||||
|
>
|
||||||
|
Also reset token
|
||||||
|
</Checkbox>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!transferTarget}
|
||||||
|
onClick={() =>
|
||||||
|
transferBot(
|
||||||
|
user._id,
|
||||||
|
transferTarget!._id,
|
||||||
|
transferResetToken
|
||||||
|
)
|
||||||
|
.then(() =>
|
||||||
|
toast({
|
||||||
|
title: "Reset bot token",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
toast({
|
||||||
|
title: "Failed to reset token",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
setTransferResetToken(true);
|
||||||
|
setTransferTarget(null);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost">Close Open Reports</Button>
|
<Button variant="ghost">Close Open Reports</Button>
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogDescription,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Command, CommandItem } from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
deleteEmailClassification,
|
||||||
|
updateEmailClassification,
|
||||||
|
} from "@/lib/actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const CLASSIFICATIONS = ["DISPOSABLE", "PRONE_TO_ABUSE", "ALIAS"];
|
||||||
|
|
||||||
|
export default function EmailClassificationRow({
|
||||||
|
domain,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
domain: string;
|
||||||
|
classification: string;
|
||||||
|
}) {
|
||||||
|
const [classification, setClassification] = useState(props.classification);
|
||||||
|
const [selectClassification, setSelectClassification] = useState(false);
|
||||||
|
const [deleted, setDeleted] = useState(false);
|
||||||
|
|
||||||
|
return deleted ? null : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{domain}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Popover
|
||||||
|
open={selectClassification}
|
||||||
|
onOpenChange={setSelectClassification}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={selectClassification}
|
||||||
|
>
|
||||||
|
{classification}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<Command>
|
||||||
|
{CLASSIFICATIONS.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c}
|
||||||
|
onSelect={async () => {
|
||||||
|
try {
|
||||||
|
await updateEmailClassification(domain, c);
|
||||||
|
|
||||||
|
setSelectClassification(false);
|
||||||
|
setClassification(c);
|
||||||
|
toast({
|
||||||
|
title: "Classification updated",
|
||||||
|
description: `${domain} is now classified as ${c}`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to update classification",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
c == classification ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{c}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button>Remove</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Delete classification for {domain}?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteEmailClassification(domain);
|
||||||
|
setDeleted(true);
|
||||||
|
toast({
|
||||||
|
title: "Classification deleted",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to delete classification",
|
||||||
|
description: String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
@ -14,6 +14,8 @@ const badgeVariants = cva(
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
"relatively-destructive":
|
||||||
|
"border-transparent bg-destructive/60 text-destructive-foreground hover:bg-destructive/40",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -21,7 +23,7 @@ const badgeVariants = cva(
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
@ -30,7 +32,7 @@ export interface BadgeProps
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Input } from "./input";
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "./card";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||||
|
import { User } from "revolt-api";
|
||||||
|
import { AUTUMN_URL } from "@/lib/constants";
|
||||||
|
import { fetchUserById } from "@/lib/db";
|
||||||
|
|
||||||
|
export default function UserSelector({ onChange }: {
|
||||||
|
onChange?: (user: User | null) => any,
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (input.length != 26) {
|
||||||
|
onChange?.(null);
|
||||||
|
if (user) setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!searching) return;
|
||||||
|
if (input != user?._id) {
|
||||||
|
setSearching(true);
|
||||||
|
fetchUserById(input)
|
||||||
|
.then((user) => {
|
||||||
|
setUser(user);
|
||||||
|
onChange?.(user);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setUser(null);
|
||||||
|
onChange?.(null);
|
||||||
|
})
|
||||||
|
.finally(() => setSearching(false));
|
||||||
|
}
|
||||||
|
else setUser(null);
|
||||||
|
}, [input, user, searching, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className="rounded-b-none"
|
||||||
|
style={{ boxShadow: "none" }} // doing this with tailwind just... doesnt work
|
||||||
|
placeholder="Enter an ID..."
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.currentTarget.value);
|
||||||
|
setSearching(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Card className="border-t-0 rounded-t-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className={`flex items-center gap-1 ${user ? "" : "text-gray-400"}`}>
|
||||||
|
<Avatar>
|
||||||
|
{user && <AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar?._id}`} />}
|
||||||
|
<AvatarFallback className="overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||||
|
{user
|
||||||
|
? (user.display_name ?? user.username)
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((x) => String.fromCodePoint(x.codePointAt(0) ?? 32) ?? "")
|
||||||
|
.join("")
|
||||||
|
: "?"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{user
|
||||||
|
? <>{user.username}#{user.discriminator} {user.display_name}</>
|
||||||
|
: "User#0000"
|
||||||
|
}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{
|
||||||
|
!input
|
||||||
|
? "Enter an ID..."
|
||||||
|
: input.length != 26
|
||||||
|
? "Invalid ID"
|
||||||
|
: searching
|
||||||
|
? "Searching..."
|
||||||
|
: user
|
||||||
|
? "User exists!"
|
||||||
|
: "Unknown user"
|
||||||
|
}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
with import <nixpkgs> { };
|
||||||
|
pkgs.mkShell {
|
||||||
|
name = "adminEnv";
|
||||||
|
buildInputs = [
|
||||||
|
pkgs.git
|
||||||
|
pkgs.nodejs
|
||||||
|
pkgs.nodePackages.pnpm
|
||||||
|
];
|
||||||
|
}
|
|
@ -1,55 +1,91 @@
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { insertAuditLog } from "./db";
|
import { SafetyNotes, insertAuditLog } from "./db";
|
||||||
|
|
||||||
type Permission =
|
type Permission =
|
||||||
| "authifier"
|
| `authifier${
|
||||||
|
| ""
|
||||||
|
| `/classification${"" | "/fetch" | "/create" | "/update" | "/delete"}`}`
|
||||||
| "publish_message"
|
| "publish_message"
|
||||||
| "chat_message"
|
| "chat_message"
|
||||||
| `accounts${
|
| `accounts${
|
||||||
| ""
|
| ""
|
||||||
| `/fetch${"" | "/by-id"}`
|
| `/fetch${"" | "/by-id" | "/by-email"}`
|
||||||
| "/update/email"
|
| `/update${"" | "/email" | "/mfa"}`
|
||||||
| "/disable"
|
| "/disable"
|
||||||
| "/restore"
|
| "/restore"
|
||||||
| `/deletion${"" | "/queue" | "/cancel"}`}`
|
| `/deletion${"" | "/queue" | "/cancel"}`}`
|
||||||
| `bots${
|
| `bots${
|
||||||
| ""
|
| ""
|
||||||
| `/fetch${"" | "/by-id" | "/by-user"}`
|
| `/fetch${"" | "/by-id" | "/by-user"}`
|
||||||
| `/update${"" | "/discoverability"}`}`
|
| `/update${"" | "/discoverability" | "/owner" | "/reset-token"}`}`
|
||||||
| `channels${"" | `/fetch${"" | "/by-id" | "/dm"}` | `/create${"" | "/dm"}`}`
|
| `channels${
|
||||||
|
| ""
|
||||||
|
| `/fetch${"" | "/by-id" | "/by-server" | "/dm" | "/invites"}`
|
||||||
|
| `/create${"" | "/dm" | "/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"}`}`
|
||||||
| `servers${
|
| `servers${
|
||||||
| ""
|
| ""
|
||||||
| `/fetch${"" | "/by-id"}`
|
| `/fetch${"" | "/by-id"}`
|
||||||
| `/update${"" | "/flags" | "/discoverability"}`}`
|
| `/update${
|
||||||
|
| ""
|
||||||
|
| "/flags"
|
||||||
|
| "/discoverability"
|
||||||
|
| "/owner"
|
||||||
|
| "/add-member"
|
||||||
|
| "/quarantine"}`}`
|
||||||
| `users${
|
| `users${
|
||||||
| ""
|
| ""
|
||||||
| `/fetch${
|
| `/fetch${
|
||||||
| ""
|
| ""
|
||||||
| "/by-id"
|
| "/by-id"
|
||||||
|
| "/by-tag"
|
||||||
|
| "/bulk-by-username"
|
||||||
| "/memberships"
|
| "/memberships"
|
||||||
| "/strikes"
|
| "/strikes"
|
||||||
| "/notices"
|
| "/notices"
|
||||||
| "/relations"}`
|
| "/relations"}`
|
||||||
| `/create${"" | "/alert" | "/strike"}`
|
| `/create${"" | "/alert" | "/strike"}`
|
||||||
| `/update${"" | "/badges"}`
|
| `/update${"" | "/badges"}`
|
||||||
| `/action${"" | "/unsuspend" | "/suspend" | "/wipe" | "/ban" | "/wipe-profile"}`}`;
|
| `/action${
|
||||||
|
| ""
|
||||||
|
| "/unsuspend"
|
||||||
|
| "/suspend"
|
||||||
|
| "/wipe"
|
||||||
|
| "/ban"
|
||||||
|
| "/wipe-profile"}`}`
|
||||||
|
| `safety_notes${
|
||||||
|
| ""
|
||||||
|
| `/fetch${"" | `/${SafetyNotes["_id"]["type"]}`}`
|
||||||
|
| `/update${"" | `/${SafetyNotes["_id"]["type"]}`}`}`
|
||||||
|
| `backup${"" | `/fetch${"" | "/by-name"}`}`;
|
||||||
|
|
||||||
const PermissionSets = {
|
const PermissionSets = {
|
||||||
// Admin
|
// Admin
|
||||||
|
@ -65,11 +101,14 @@ const PermissionSets = {
|
||||||
"sessions",
|
"sessions",
|
||||||
"servers",
|
"servers",
|
||||||
"users",
|
"users",
|
||||||
|
"safety_notes",
|
||||||
] as Permission[],
|
] as Permission[],
|
||||||
|
|
||||||
// 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",
|
||||||
|
@ -81,16 +120,28 @@ 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
|
||||||
"revolt-discover": [
|
"revolt-discover": [
|
||||||
"servers/fetch/by-id",
|
"servers/fetch/by-id",
|
||||||
"servers/update/discoverability",
|
"servers/update/discoverability",
|
||||||
|
"servers/update/flags",
|
||||||
|
|
||||||
"bots/fetch/by-id",
|
"bots/fetch/by-id",
|
||||||
"bots/update/discoverability",
|
"bots/update/discoverability",
|
||||||
|
|
||||||
|
"safety_notes/fetch/global",
|
||||||
|
"safety_notes/fetch/server",
|
||||||
|
"safety_notes/fetch/user",
|
||||||
|
"safety_notes/update/server",
|
||||||
|
"safety_notes/update/user",
|
||||||
] as Permission[],
|
] as Permission[],
|
||||||
|
|
||||||
// User support
|
// User support
|
||||||
|
@ -100,33 +151,60 @@ const PermissionSets = {
|
||||||
"users/fetch/notices",
|
"users/fetch/notices",
|
||||||
"users/update/badges",
|
"users/update/badges",
|
||||||
|
|
||||||
|
"servers/update/owner",
|
||||||
|
|
||||||
|
"bots/fetch/by-user",
|
||||||
|
"bots/update/reset-token",
|
||||||
|
"bots/update/owner",
|
||||||
|
|
||||||
"accounts/fetch/by-id",
|
"accounts/fetch/by-id",
|
||||||
|
"accounts/fetch/by-email",
|
||||||
"accounts/disable",
|
"accounts/disable",
|
||||||
"accounts/restore",
|
"accounts/restore",
|
||||||
"accounts/deletion/queue",
|
"accounts/deletion/queue",
|
||||||
"accounts/deletion/cancel",
|
"accounts/deletion/cancel",
|
||||||
"accounts/update/email",
|
"accounts/update/email",
|
||||||
|
"accounts/update/mfa",
|
||||||
|
|
||||||
|
"channels/update/invites",
|
||||||
|
"channels/fetch/invites",
|
||||||
|
|
||||||
|
"safety_notes/fetch",
|
||||||
|
"safety_notes/update",
|
||||||
] as Permission[],
|
] as Permission[],
|
||||||
|
|
||||||
// Moderate users
|
// Moderate users
|
||||||
"moderate-users": [
|
"moderate-users": [
|
||||||
"users/fetch/by-id",
|
"users/fetch/by-id",
|
||||||
|
"users/fetch/by-tag",
|
||||||
|
"users/fetch/bulk-by-username",
|
||||||
"users/fetch/strikes",
|
"users/fetch/strikes",
|
||||||
"users/fetch/notices",
|
"users/fetch/notices",
|
||||||
|
|
||||||
"bots/fetch/by-user",
|
"bots/fetch/by-user",
|
||||||
|
"bots/update/reset-token",
|
||||||
|
"bots/update/owner",
|
||||||
|
|
||||||
// "messages/fetch/by-user",
|
// "messages/fetch/by-user",
|
||||||
// "users/fetch/memberships",
|
"users/fetch/memberships",
|
||||||
|
"users/fetch/relations",
|
||||||
"servers/fetch",
|
"servers/fetch",
|
||||||
|
|
||||||
"messages/fetch/by-id",
|
"messages/fetch/by-id",
|
||||||
"channels/fetch/by-id",
|
"channels/fetch/by-id",
|
||||||
"channels/fetch/dm",
|
"channels/fetch/dm",
|
||||||
|
"channels/fetch/invites",
|
||||||
"channels/create/dm",
|
"channels/create/dm",
|
||||||
|
|
||||||
|
"servers/update/quarantine",
|
||||||
|
"servers/update/owner",
|
||||||
|
"servers/update/add-member",
|
||||||
|
"backup/fetch",
|
||||||
|
|
||||||
"reports/fetch/related/by-user",
|
"reports/fetch/related/by-user",
|
||||||
"reports/fetch/related/by-content",
|
"reports/fetch/related/by-content",
|
||||||
"reports/fetch/related/against-user",
|
"reports/fetch/related/against-user",
|
||||||
|
"reports/update/bulk-close/by-user",
|
||||||
|
|
||||||
"users/create/alert",
|
"users/create/alert",
|
||||||
"users/create/strike",
|
"users/create/strike",
|
||||||
|
@ -140,7 +218,12 @@ const PermissionSets = {
|
||||||
|
|
||||||
"publish_message",
|
"publish_message",
|
||||||
"chat_message",
|
"chat_message",
|
||||||
|
|
||||||
|
"safety_notes/fetch",
|
||||||
|
"safety_notes/update",
|
||||||
] as Permission[],
|
] as Permission[],
|
||||||
|
|
||||||
|
authifier: ["authifier/classification"] as Permission[],
|
||||||
};
|
};
|
||||||
|
|
||||||
const Roles = {
|
const Roles = {
|
||||||
|
@ -148,6 +231,7 @@ const Roles = {
|
||||||
...PermissionSets["view-open-reports"],
|
...PermissionSets["view-open-reports"],
|
||||||
...PermissionSets["edit-reports"],
|
...PermissionSets["edit-reports"],
|
||||||
...PermissionSets["moderate-users"],
|
...PermissionSets["moderate-users"],
|
||||||
|
...PermissionSets["authifier"],
|
||||||
],
|
],
|
||||||
"user-support": [...PermissionSets["user-support"]],
|
"user-support": [...PermissionSets["user-support"]],
|
||||||
"revolt-discover": [...PermissionSets["revolt-discover"]],
|
"revolt-discover": [...PermissionSets["revolt-discover"]],
|
||||||
|
@ -160,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"],
|
||||||
|
@ -212,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);
|
||||||
}
|
}
|
||||||
|
|
570
lib/actions.ts
570
lib/actions.ts
|
@ -1,15 +1,15 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { writeFile } from "fs/promises";
|
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,
|
||||||
|
EmailClassification,
|
||||||
|
ReportDocument,
|
||||||
createDM,
|
createDM,
|
||||||
fetchAccountById,
|
fetchAccountById,
|
||||||
fetchChannels,
|
|
||||||
fetchMembershipsByUser,
|
|
||||||
fetchMessages,
|
|
||||||
fetchUserById,
|
|
||||||
findDM,
|
findDM,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { publishMessage, sendChatMessage } from "./redis";
|
import { publishMessage, sendChatMessage } from "./redis";
|
||||||
|
@ -20,6 +20,7 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
Channel,
|
Channel,
|
||||||
File,
|
File,
|
||||||
|
Invite,
|
||||||
Member,
|
Member,
|
||||||
Message,
|
Message,
|
||||||
Report,
|
Report,
|
||||||
|
@ -28,6 +29,8 @@ import {
|
||||||
User,
|
User,
|
||||||
} from "revolt-api";
|
} from "revolt-api";
|
||||||
import { checkPermission } from "./accessPermissions";
|
import { checkPermission } from "./accessPermissions";
|
||||||
|
import { Long } from "mongodb";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export async function sendAlert(userId: string, content: string) {
|
export async function sendAlert(userId: string, content: string) {
|
||||||
await checkPermission("users/create/alert", userId, { content });
|
await checkPermission("users/create/alert", userId, { content });
|
||||||
|
@ -97,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);
|
||||||
|
|
||||||
|
@ -115,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 });
|
||||||
|
|
||||||
|
@ -157,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);
|
||||||
|
|
||||||
|
@ -194,6 +274,40 @@ export async function disableAccount(userId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteMFARecoveryCodes(userId: string) {
|
||||||
|
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||||
|
await checkPermission("accounts/update/mfa", userId);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Account>("accounts")
|
||||||
|
.updateOne(
|
||||||
|
{ _id: userId },
|
||||||
|
{
|
||||||
|
$unset: {
|
||||||
|
"mfa.recovery_codes": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableMFA(userId: string) {
|
||||||
|
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||||
|
await checkPermission("accounts/update/mfa", userId);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Account>("accounts")
|
||||||
|
.updateOne(
|
||||||
|
{ _id: userId },
|
||||||
|
{
|
||||||
|
$unset: {
|
||||||
|
"mfa.totp_token": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function changeAccountEmail(userId: string, email: string) {
|
export async function changeAccountEmail(userId: string, email: string) {
|
||||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||||
await checkPermission("accounts/update/email", userId);
|
await checkPermission("accounts/update/email", userId);
|
||||||
|
@ -249,6 +363,20 @@ export async function verifyAccountEmail(userId: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function lookupEmail(email: string): Promise<string | false> {
|
||||||
|
await checkPermission("accounts/fetch/by-email", email);
|
||||||
|
|
||||||
|
const accounts = mongo().db("revolt").collection<Account>("accounts");
|
||||||
|
|
||||||
|
let result = await accounts.findOne({ email: email });
|
||||||
|
if (result) return result._id;
|
||||||
|
|
||||||
|
result = await accounts.findOne({ email_normalised: email });
|
||||||
|
if (result) return result._id;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function suspendUser(userId: string) {
|
export async function suspendUser(userId: string) {
|
||||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||||
|
|
||||||
|
@ -269,7 +397,12 @@ export async function suspendUser(userId: string) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const memberships = await fetchMembershipsByUser(userId);
|
const memberships = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<{ _id: { user: string; server: string } }>("server_members")
|
||||||
|
.find({ "_id.user": userId })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
for (const topic of memberships.map((x) => x._id.server)) {
|
for (const topic of memberships.map((x) => x._id.server)) {
|
||||||
await publishMessage(topic, {
|
await publishMessage(topic, {
|
||||||
type: "UserUpdate",
|
type: "UserUpdate",
|
||||||
|
@ -313,12 +446,18 @@ 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
|
||||||
|
? null
|
||||||
|
: await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<User>("users")
|
.collection<User>("users")
|
||||||
.findOne({ _id: userId });
|
.findOne({ _id: userId });
|
||||||
|
@ -329,7 +468,9 @@ 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
|
||||||
|
? null
|
||||||
|
: await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<Channel>("channels")
|
.collection<Channel>("channels")
|
||||||
.find({
|
.find({
|
||||||
|
@ -338,7 +479,9 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
})
|
})
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
const memberships = await mongo()
|
const memberships = onlyMessages
|
||||||
|
? null
|
||||||
|
: await mongo()
|
||||||
.db("revolt")
|
.db("revolt")
|
||||||
.collection<{ _id: { user: string; server: string } }>("server_members")
|
.collection<{ _id: { user: string; server: string } }>("server_members")
|
||||||
.find({ "_id.user": userId })
|
.find({ "_id.user": userId })
|
||||||
|
@ -346,7 +489,7 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
|
|
||||||
// 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,
|
||||||
|
@ -366,6 +509,7 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
.filter((attachment) => attachment)
|
.filter((attachment) => attachment)
|
||||||
.map((attachment) => attachment!._id);
|
.map((attachment) => attachment!._id);
|
||||||
|
|
||||||
|
if (!onlyMessages) {
|
||||||
if (backup.user?.avatar) {
|
if (backup.user?.avatar) {
|
||||||
attachmentIds.push(backup.user.avatar._id);
|
attachmentIds.push(backup.user.avatar._id);
|
||||||
}
|
}
|
||||||
|
@ -373,6 +517,7 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
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) {
|
||||||
await mongo()
|
await mongo()
|
||||||
|
@ -394,6 +539,7 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
author: userId,
|
author: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!onlyMessages) {
|
||||||
// delete server memberships
|
// delete server memberships
|
||||||
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
|
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
|
||||||
"_id.user": userId,
|
"_id.user": userId,
|
||||||
|
@ -424,8 +570,8 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
|
|
||||||
// broadcast wipe event
|
// broadcast wipe event
|
||||||
for (const topic of [
|
for (const topic of [
|
||||||
...backup.dms.map((x) => x._id),
|
...backup.dms!.map((x) => x._id),
|
||||||
...backup.memberships.map((x) => x._id.server),
|
...backup.memberships!.map((x) => x._id.server),
|
||||||
]) {
|
]) {
|
||||||
await publishMessage(topic, {
|
await publishMessage(topic, {
|
||||||
type: "UserPlatformWipe",
|
type: "UserPlatformWipe",
|
||||||
|
@ -434,6 +580,7 @@ export async function wipeUser(userId: string, flags = 4) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function banUser(userId: string) {
|
export async function banUser(userId: string) {
|
||||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||||
|
@ -536,6 +683,222 @@ export async function updateServerDiscoverability(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateServerOwner(serverId: string, userId: string) {
|
||||||
|
await checkPermission("servers/update/owner", { serverId, userId });
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Server>("servers")
|
||||||
|
.updateOne({ _id: serverId }, { $set: { owner: userId } });
|
||||||
|
|
||||||
|
await publishMessage(serverId, {
|
||||||
|
type: "ServerUpdate",
|
||||||
|
id: serverId,
|
||||||
|
data: {
|
||||||
|
owner: userId,
|
||||||
|
},
|
||||||
|
clear: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addServerMember(
|
||||||
|
serverId: string,
|
||||||
|
userId: string,
|
||||||
|
withEvent: boolean
|
||||||
|
) {
|
||||||
|
await checkPermission("servers/update/add-member", {
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
withEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Server>("servers")
|
||||||
|
.findOne({ _id: serverId });
|
||||||
|
|
||||||
|
const channels = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Channel>("channels")
|
||||||
|
.find({ server: serverId })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const member = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Member>("server_members")
|
||||||
|
.findOne({ _id: { server: serverId, user: userId } });
|
||||||
|
|
||||||
|
if (!server) throw new Error("server doesn't exist");
|
||||||
|
if (member) throw new Error("already a member");
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Member>("server_members")
|
||||||
|
.insertOne({
|
||||||
|
_id: { server: serverId, user: userId },
|
||||||
|
joined_at: Long.fromNumber(Date.now()) as unknown as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
await publishMessage(userId + "!", {
|
||||||
|
type: "ServerCreate",
|
||||||
|
id: serverId,
|
||||||
|
channels: channels,
|
||||||
|
server: server,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (withEvent) {
|
||||||
|
await publishMessage(serverId, {
|
||||||
|
type: "ServerMemberJoin",
|
||||||
|
id: serverId,
|
||||||
|
user: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function quarantineServer(serverId: string, message: string) {
|
||||||
|
await checkPermission("servers/update/quarantine", { serverId, message });
|
||||||
|
|
||||||
|
const server = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Server>("servers")
|
||||||
|
.findOne({ _id: serverId });
|
||||||
|
|
||||||
|
const members = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Member>("server_members")
|
||||||
|
.find({ "_id.server": serverId })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const invites = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Invite>("channel_invites")
|
||||||
|
.find({ type: "Server", server: serverId })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (!server) throw new Error("server doesn't exist");
|
||||||
|
|
||||||
|
const backup = {
|
||||||
|
_event: "quarantine",
|
||||||
|
server,
|
||||||
|
members,
|
||||||
|
invites,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
`./exports/${new Date().toISOString()} - ${serverId}.json`,
|
||||||
|
JSON.stringify(backup)
|
||||||
|
);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Server>("servers")
|
||||||
|
.updateOne(
|
||||||
|
{ _id: serverId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
owner: "0".repeat(26),
|
||||||
|
analytics: false,
|
||||||
|
discoverable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Member>("server_members")
|
||||||
|
.deleteMany({ "_id.server": serverId });
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Invite>("channel_invites")
|
||||||
|
.deleteMany({ type: "Server", server: serverId });
|
||||||
|
|
||||||
|
await publishMessage(serverId, {
|
||||||
|
type: "ServerDelete",
|
||||||
|
id: serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
while (members.length) {
|
||||||
|
const m = members.splice(0, 50);
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
m.map(async (member) => {
|
||||||
|
const messageId = ulid();
|
||||||
|
|
||||||
|
let dm = await findDM(PLATFORM_MOD_ID, member._id.user);
|
||||||
|
if (!dm)
|
||||||
|
dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
|
||||||
|
|
||||||
|
await sendChatMessage({
|
||||||
|
_id: messageId,
|
||||||
|
author: PLATFORM_MOD_ID,
|
||||||
|
channel: dm._id,
|
||||||
|
content: message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInvite(invite: string) {
|
||||||
|
await checkPermission("channels/update/invites", invite);
|
||||||
|
|
||||||
|
if (!invite) throw new Error("invite is empty");
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.deleteOne({ _id: invite });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editInvite(invite: string, newInvite: string) {
|
||||||
|
await checkPermission("channels/update/invites", { invite, newInvite });
|
||||||
|
|
||||||
|
if (!invite) throw new Error("invite is empty");
|
||||||
|
if (!newInvite) throw new Error("new invite is empty");
|
||||||
|
|
||||||
|
const { value } = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.findOneAndDelete({ _id: invite });
|
||||||
|
|
||||||
|
if (!value) throw new Error("invite doesn't exist");
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.insertOne({ ...value, _id: newInvite });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editInviteChannel(invite: string, newChannel: string) {
|
||||||
|
await checkPermission("channels/update/invites", { invite, newChannel });
|
||||||
|
|
||||||
|
if (!invite) throw new Error("invite is empty");
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.updateOne({ _id: invite }, { $set: { channel: newChannel } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvite(invite: ChannelInvite) {
|
||||||
|
await checkPermission("channels/update/invites", invite);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.insertOne(invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteInvites(invites: string[]) {
|
||||||
|
await checkPermission("channels/update/invites", invites);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.deleteMany({ _id: { $in: invites } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateBotDiscoverability(botId: string, state: boolean) {
|
export async function updateBotDiscoverability(botId: string, state: boolean) {
|
||||||
await checkPermission("bots/update/discoverability", botId, { state });
|
await checkPermission("bots/update/discoverability", botId, { state });
|
||||||
await mongo()
|
await mongo()
|
||||||
|
@ -554,6 +917,83 @@ export async function updateBotDiscoverability(botId: string, state: boolean) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetBotToken(botId: string) {
|
||||||
|
await checkPermission("bots/update/reset-token", { botId });
|
||||||
|
|
||||||
|
// Should generate tokens the exact same as the backend generates them:
|
||||||
|
// https://github.com/revoltchat/backend/blob/41f20c2239ed6307ad821b321d13240dc6ff3327/crates/core/database/src/models/bots/model.rs#L106
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Bot>("bots")
|
||||||
|
.updateOne(
|
||||||
|
{
|
||||||
|
_id: botId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
token: nanoid(64),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferBot(
|
||||||
|
botId: string,
|
||||||
|
ownerId: string,
|
||||||
|
resetToken: boolean
|
||||||
|
) {
|
||||||
|
await checkPermission("bots/update/owner", { botId, ownerId, resetToken });
|
||||||
|
|
||||||
|
if (resetToken) {
|
||||||
|
await checkPermission("bots/update/reset-token", { botId });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Bot>("bots")
|
||||||
|
.updateOne(
|
||||||
|
{
|
||||||
|
_id: botId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
owner: ownerId,
|
||||||
|
...(resetToken
|
||||||
|
? {
|
||||||
|
token: nanoid(64),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<User>("users")
|
||||||
|
.updateOne(
|
||||||
|
{
|
||||||
|
_id: botId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"bot.owner": ownerId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless.
|
||||||
|
await publishMessage(botId, {
|
||||||
|
type: "UserUpdate",
|
||||||
|
id: botId,
|
||||||
|
data: {
|
||||||
|
bot: {
|
||||||
|
owner: ownerId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreAccount(accountId: string) {
|
export async function restoreAccount(accountId: string) {
|
||||||
if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access";
|
if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access";
|
||||||
await checkPermission("accounts/restore", accountId);
|
await checkPermission("accounts/restore", accountId);
|
||||||
|
@ -616,3 +1056,107 @@ export async function cancelAccountDeletion(accountId: string) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchBackups() {
|
||||||
|
await checkPermission("backup/fetch", null);
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
(
|
||||||
|
await readdir("./exports", { withFileTypes: true })
|
||||||
|
)
|
||||||
|
.filter((file) => file.isFile() && file.name.endsWith(".json"))
|
||||||
|
.map(async (file) => {
|
||||||
|
let type: string | null = null;
|
||||||
|
try {
|
||||||
|
type = JSON.parse(
|
||||||
|
(await readFile(`./exports/${file.name}`)).toString("utf-8")
|
||||||
|
)._event;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return { name: file.name, type: type };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBackup(name: string) {
|
||||||
|
await checkPermission("backup/fetch/by-name", null);
|
||||||
|
|
||||||
|
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmailClassifications(): Promise<
|
||||||
|
EmailClassification[]
|
||||||
|
> {
|
||||||
|
await checkPermission("authifier/classification/fetch", null);
|
||||||
|
|
||||||
|
return await mongo()
|
||||||
|
.db("authifier")
|
||||||
|
.collection<EmailClassification>("email_classification")
|
||||||
|
.find({})
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmailClassification(
|
||||||
|
domain: string,
|
||||||
|
classification: string
|
||||||
|
) {
|
||||||
|
await checkPermission("authifier/classification/create", {
|
||||||
|
domain,
|
||||||
|
classification,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("authifier")
|
||||||
|
.collection<EmailClassification>("email_classification")
|
||||||
|
.insertOne({ _id: domain, classification });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEmailClassification(
|
||||||
|
domain: string,
|
||||||
|
classification: string
|
||||||
|
) {
|
||||||
|
await checkPermission("authifier/classification/update", {
|
||||||
|
domain,
|
||||||
|
classification,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("authifier")
|
||||||
|
.collection<EmailClassification>("email_classification")
|
||||||
|
.updateOne({ _id: domain }, { $set: { classification } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEmailClassification(domain: string) {
|
||||||
|
await checkPermission("authifier/classification/delete", domain);
|
||||||
|
|
||||||
|
await mongo()
|
||||||
|
.db("authifier")
|
||||||
|
.collection<EmailClassification>("email_classification")
|
||||||
|
.deleteOne({ _id: domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUserByTag(
|
||||||
|
username: string,
|
||||||
|
discriminator: string
|
||||||
|
): Promise<string | false> {
|
||||||
|
await checkPermission("users/fetch/by-tag", { username, discriminator });
|
||||||
|
|
||||||
|
const result = await mongo().db("revolt").collection<User>("users").findOne({
|
||||||
|
username,
|
||||||
|
discriminator,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result?._id || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsersByUsername(username: string) {
|
||||||
|
await checkPermission("users/fetch/bulk-by-username", { username });
|
||||||
|
|
||||||
|
return await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<User>("users")
|
||||||
|
.find({
|
||||||
|
username,
|
||||||
|
})
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Server } from "revolt-api";
|
||||||
|
|
||||||
export const PLATFORM_MOD_ID =
|
export const PLATFORM_MOD_ID =
|
||||||
process.env.PLATFORM_MOD_ID || "01FC17E1WTM2BGE4F3ARN3FDAF";
|
process.env.PLATFORM_MOD_ID || "01FC17E1WTM2BGE4F3ARN3FDAF";
|
||||||
export const AUTUMN_URL =
|
export const AUTUMN_URL =
|
||||||
|
@ -14,3 +16,5 @@ export const RESTRICT_ACCESS_LIST = [
|
||||||
"01EXAHMSGNDCAZTJXDJZ0BK8N3", //- wait what
|
"01EXAHMSGNDCAZTJXDJZ0BK8N3", //- wait what
|
||||||
"01FEEFJCKY5C4DMMJYZ20ACWWC", //- rexo
|
"01FEEFJCKY5C4DMMJYZ20ACWWC", //- rexo
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const SEVRER_REMOVAL_MESSAGE = (server: Server) => `A server you were in ("${server.name}") has been removed from Revolt for the following reason:\n- CHANGEME`;
|
||||||
|
|
186
lib/db.ts
186
lib/db.ts
|
@ -1,10 +1,11 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { Filter, MongoClient } from "mongodb";
|
import { Filter, MongoClient, WithId } from "mongodb";
|
||||||
import type {
|
import type {
|
||||||
AccountStrike,
|
AccountStrike,
|
||||||
Bot,
|
Bot,
|
||||||
Channel,
|
Channel,
|
||||||
|
Invite,
|
||||||
Message,
|
Message,
|
||||||
Report,
|
Report,
|
||||||
Server,
|
Server,
|
||||||
|
@ -20,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!);
|
||||||
|
@ -54,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) {
|
||||||
|
@ -104,23 +120,44 @@ export type Account = {
|
||||||
attempts: number;
|
attempts: number;
|
||||||
expiry: string;
|
expiry: string;
|
||||||
};
|
};
|
||||||
|
mfa?: {
|
||||||
|
totp_token?: {
|
||||||
|
status: "Pending" | "Enabled";
|
||||||
|
};
|
||||||
|
recovery_codes?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
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")
|
||||||
.findOne(
|
.aggregate([
|
||||||
{ _id: id },
|
|
||||||
{
|
{
|
||||||
projection: {
|
$match: { _id: id },
|
||||||
password: 0,
|
|
||||||
mfa: 0,
|
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
);
|
$project: {
|
||||||
|
password: 0,
|
||||||
|
"mfa.totp_token.secret": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
// Replace recovery code array with amount of codes
|
||||||
|
"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) {
|
||||||
|
@ -264,6 +301,33 @@ export async function fetchServers(query: Filter<Server>) {
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `vanity` should eventually be added to the backend as well
|
||||||
|
export type ChannelInvite = Invite & { vanity?: boolean };
|
||||||
|
|
||||||
|
export async function fetchInvites(query: Filter<ChannelInvite>) {
|
||||||
|
await checkPermission("channels/fetch/invites", query);
|
||||||
|
|
||||||
|
const invites = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<ChannelInvite>("channel_invites")
|
||||||
|
.find(query)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const channels = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<Channel>("channels")
|
||||||
|
.find({ _id: { $in: invites.map((invite) => invite.channel) } })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const users = await mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<User>("users")
|
||||||
|
.find({ _id: { $in: invites.map((invite) => invite.creator) } })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return { invites, channels, users };
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchMessageById(id: string) {
|
export async function fetchMessageById(id: string) {
|
||||||
await checkPermission("messages/fetch/by-id", id);
|
await checkPermission("messages/fetch/by-id", id);
|
||||||
|
|
||||||
|
@ -308,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" },
|
||||||
{
|
{
|
||||||
|
@ -320,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);
|
||||||
|
|
||||||
|
@ -354,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);
|
||||||
|
|
||||||
|
@ -413,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);
|
||||||
|
|
||||||
|
@ -510,3 +629,50 @@ export async function fetchAuthifierEmailClassification(provider: string) {
|
||||||
.collection<EmailClassification>("email_classification")
|
.collection<EmailClassification>("email_classification")
|
||||||
.findOne({ _id: provider });
|
.findOne({ _id: provider });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SafetyNotes = {
|
||||||
|
_id: {
|
||||||
|
id: string;
|
||||||
|
type: "message" | "channel" | "server" | "user" | "account" | "global";
|
||||||
|
};
|
||||||
|
text: string;
|
||||||
|
edited_by: string;
|
||||||
|
edited_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchSafetyNote(
|
||||||
|
objectId: string,
|
||||||
|
type: SafetyNotes["_id"]["type"]
|
||||||
|
) {
|
||||||
|
await checkPermission(`safety_notes/fetch/${type}`, objectId);
|
||||||
|
|
||||||
|
return mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<SafetyNotes>("safety_notes")
|
||||||
|
.findOne({ _id: { id: objectId, type: type } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSafetyNote(
|
||||||
|
objectId: string,
|
||||||
|
type: SafetyNotes["_id"]["type"],
|
||||||
|
note: string
|
||||||
|
) {
|
||||||
|
await checkPermission(`safety_notes/update/${type}`, objectId);
|
||||||
|
|
||||||
|
const session = await getServerSession();
|
||||||
|
|
||||||
|
return mongo()
|
||||||
|
.db("revolt")
|
||||||
|
.collection<SafetyNotes>("safety_notes")
|
||||||
|
.updateOne(
|
||||||
|
{ _id: { id: objectId, type: type } },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
text: note,
|
||||||
|
edited_at: new Date(Date.now()),
|
||||||
|
edited_by: session?.user?.email ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
import { NtfyClient, MessagePriority } from 'ntfy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NTFY_SERVER
|
||||||
|
* NTFY_TOPIC
|
||||||
|
* NTFY_USERNAME
|
||||||
|
* NTFY_PASSWORD
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!process.env.NTFY_TOPIC) {
|
||||||
|
console.log('$NTFY_TOPIC not set');
|
||||||
|
} else {
|
||||||
|
console.log('Listening for new reports');
|
||||||
|
|
||||||
|
const ntfy = new NtfyClient(process.env.NTFY_SERVER);
|
||||||
|
const redis = createClient({
|
||||||
|
url: process.env.REDIS,
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.SUBSCRIBE('global', async (message) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(message);
|
||||||
|
if (event.type != "ReportCreate") return;
|
||||||
|
console.log('New report:', event.content);
|
||||||
|
|
||||||
|
await ntfy.publish({
|
||||||
|
title: `Report created (${event.content.type}, ${event.content.report_reason})`,
|
||||||
|
message: event.additional_context || "No reason provided",
|
||||||
|
iconURL: 'https://admin.revolt.chat/attention.png',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'View report',
|
||||||
|
type: 'view',
|
||||||
|
url: `https://admin.revolt.chat/panel/reports/${event._id}`,
|
||||||
|
clear: true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
priority: event.content.report_reason.includes('Illegal')
|
||||||
|
? MessagePriority.HIGH
|
||||||
|
: MessagePriority.DEFAULT,
|
||||||
|
topic: process.env.NTFY_TOPIC,
|
||||||
|
authorization: process.env.NTFY_USERNAME && process.env.NTFY_PASSWORD
|
||||||
|
? {
|
||||||
|
username: process.env.NTFY_USERNAME,
|
||||||
|
password: process.env.NTFY_PASSWORD,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.connect();
|
||||||
|
}
|
|
@ -19,6 +19,7 @@
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-toast": "^1.1.4",
|
"@radix-ui/react-toast": "^1.1.4",
|
||||||
|
"@types/gravatar": "^1.8.3",
|
||||||
"@types/node": "20.4.4",
|
"@types/node": "20.4.4",
|
||||||
"@types/react": "18.2.15",
|
"@types/react": "18.2.15",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
|
@ -29,15 +30,20 @@
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"eslint": "8.45.0",
|
"eslint": "8.45.0",
|
||||||
"eslint-config-next": "13.4.12",
|
"eslint-config-next": "13.4.12",
|
||||||
|
"gravatar": "^1.8.2",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lucide-react": "^0.263.0",
|
"lucide-react": "^0.263.0",
|
||||||
"mongodb": "^5.7.0",
|
"mongodb": "^5.7.0",
|
||||||
|
"nanoid": "^5.0.1",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "^4.22.3",
|
"next-auth": "^4.22.3",
|
||||||
|
"ntfy": "^1.3.1",
|
||||||
"postcss": "8.4.27",
|
"postcss": "8.4.27",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-markdown": "^8.0.7",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
"revolt-api": "^0.6.5",
|
"revolt-api": "^0.6.5",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
|
@ -47,6 +53,7 @@
|
||||||
"ulid": "^2.3.0"
|
"ulid": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@types/lodash.debounce": "^4.0.7",
|
"@types/lodash.debounce": "^4.0.7",
|
||||||
"revolt.js": "7.0.0-beta.9"
|
"revolt.js": "7.0.0-beta.9"
|
||||||
}
|
}
|
||||||
|
|
976
pnpm-lock.yaml
976
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
|
@ -38,3 +38,5 @@ app.prepare().then(() => {
|
||||||
console.log(`> Ready on http://${hostname}:${port}`);
|
console.log(`> Ready on http://${hostname}:${port}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import('./notifications.mjs');
|
||||||
|
|
|
@ -72,5 +72,5 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
}
|
}
|
Loading…
Reference in New Issue