forked from administration/panel
Compare commits
15 Commits
Author | SHA1 | Date |
---|---|---|
|
9eca727656 | |
|
a14ed14761 | |
|
915459f955 | |
|
7360333523 | |
|
32726dc7c6 | |
|
eba78e8579 | |
|
24f4357775 | |
|
44be9bf0c9 | |
|
12345018e9 | |
|
d19e15f36f | |
|
6394705f1e | |
|
5cc8b6b71d | |
|
6ac8f771f8 | |
|
2ec9584bff | |
|
6eea80d781 |
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { User } from "revolt-api";
|
|||
import { decodeTime } from "ulid";
|
||||
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default async function User({
|
||||
|
@ -40,9 +41,10 @@ export default async function User({
|
|||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<NavigationToolbar>Inspecting Account</NavigationToolbar>
|
||||
{user && <UserCard user={user} subtitle={account.email} />}
|
||||
{user && <UserCard user={user} subtitle={account.email} withLink />}
|
||||
<AccountActions account={account} user={user as User} />
|
||||
<EmailClassificationCard email={account.email} />
|
||||
<SafetyNotesCard objectId={account._id} type="account" />
|
||||
|
||||
<Separator />
|
||||
<Card>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ChannelCard } from "@/components/cards/ChannelCard";
|
||||
import { JsonCard } from "@/components/cards/JsonCard";
|
||||
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
import { ServerCard } from "@/components/cards/ServerCard";
|
||||
import { UserCard } from "@/components/cards/UserCard";
|
||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||
|
@ -34,6 +35,8 @@ export default async function Message({ params }: { params: { id: string } }) {
|
|||
</Link>
|
||||
)}
|
||||
|
||||
<SafetyNotesCard objectId={channel._id} type="channel" />
|
||||
|
||||
{participants.length ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ChannelCard } from "@/components/cards/ChannelCard";
|
||||
import { JsonCard } from "@/components/cards/JsonCard";
|
||||
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
import { UserCard } from "@/components/cards/UserCard";
|
||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
|
@ -28,6 +29,8 @@ export default async function Message({
|
|||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<SafetyNotesCard objectId={message._id} type="message" />
|
||||
|
||||
{author && (
|
||||
<Link href={`/panel/inspect/user/${author!._id}`}>
|
||||
<UserCard user={author!} subtitle="Message Author" />
|
||||
|
|
|
@ -2,14 +2,35 @@
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { lookupEmail } from "@/lib/actions";
|
||||
import { API_URL } from "@/lib/constants";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Inspect() {
|
||||
const [id, setId] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
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 createHandler = (type: string) => () =>
|
||||
router.push(`/panel/inspect/${type}/${id}`);
|
||||
|
||||
|
@ -70,6 +91,23 @@ export default function Inspect() {
|
|||
Message
|
||||
</Button>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex gap-2 justify-between">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
import { ServerCard } from "@/components/cards/ServerCard";
|
||||
import { UserCard } from "@/components/cards/UserCard";
|
||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||
|
@ -21,6 +22,7 @@ export default async function Server({ params }: { params: { id: string } }) {
|
|||
<NavigationToolbar>Inspecting Server</NavigationToolbar>
|
||||
<ServerCard server={server} subtitle="Server" />
|
||||
<ServerActions server={server} />
|
||||
<SafetyNotesCard objectId={server._id} type="server" />
|
||||
{server.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { JsonCard } from "@/components/cards/JsonCard";
|
||||
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
import { UserCard } from "@/components/cards/UserCard";
|
||||
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
|
||||
import { RecentMessages } from "@/components/pages/inspector/RecentMessages";
|
||||
|
@ -76,6 +77,7 @@ export default async function User({
|
|||
|
||||
<UserCard user={user} subtitle={user.status?.text ?? "No status set"} />
|
||||
<UserActions user={user} bot={bot as Bot} />
|
||||
<SafetyNotesCard objectId={user._id} type="user" />
|
||||
|
||||
{user.profile?.content && (
|
||||
<Card>
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import Image from "next/image";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
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: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession();
|
||||
|
||||
return (
|
||||
<main className="flex flex-col h-[100vh]">
|
||||
<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>
|
||||
</span>
|
||||
<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>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
|
||||
export default function Home() {
|
||||
return <main>this is the admin panel ever</main>;
|
||||
return <main>
|
||||
<SafetyNotesCard objectId="home" type="global" title="Bulletin board" />
|
||||
</main>;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { ReportCard } from "@/components/cards/ReportCard";
|
|||
import { CardLink } from "@/components/common/CardLink";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchOpenReports } from "@/lib/db";
|
||||
import { PizzaIcon } from "lucide-react";
|
||||
|
||||
export default async function Reports() {
|
||||
const reports = (await fetchOpenReports())
|
||||
|
@ -11,11 +12,21 @@ export default async function Reports() {
|
|||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input placeholder="Search for reports..." disabled />
|
||||
{reports.map((report) => (
|
||||
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
||||
<ReportCard report={report} />
|
||||
</CardLink>
|
||||
))}
|
||||
{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">
|
||||
You‘ve caught up for now.
|
||||
</h3>
|
||||
</>)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
"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
|
||||
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"
|
||||
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>
|
||||
)
|
||||
}
|
|
@ -3,8 +3,10 @@ import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
|||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { AUTUMN_URL } from "@/lib/constants";
|
||||
import Link from "next/link";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
export function UserCard({ user, subtitle }: { user: User; subtitle: string }) {
|
||||
export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: string, withLink?: boolean }) {
|
||||
return (
|
||||
<Card
|
||||
className="bg-no-repeat bg-right text-left"
|
||||
|
@ -28,7 +30,16 @@ export function UserCard({ user, subtitle }: { user: User; subtitle: string }) {
|
|||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{user.bot && <Badge className="align-middle">Bot</Badge>}{" "}
|
||||
{user.username}#{user.discriminator} {user.display_name}
|
||||
<div className="flex gap-2">
|
||||
{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>
|
||||
<CardDescription>{subtitle}</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
|
@ -8,7 +8,9 @@ import { User } from "revolt-api";
|
|||
import {
|
||||
cancelAccountDeletion,
|
||||
changeAccountEmail,
|
||||
deleteMFARecoveryCodes,
|
||||
disableAccount,
|
||||
disableMFA,
|
||||
queueAccountDeletion,
|
||||
restoreAccount,
|
||||
verifyAccountEmail,
|
||||
|
@ -137,6 +139,77 @@ export function AccountActions({
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
<AlertDialogTrigger asChild>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,12 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Server } from "revolt-api";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Button, buttonVariants } from "../../ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
|
@ -19,6 +16,7 @@ import { cn } from "@/lib/utils";
|
|||
import { useState } from "react";
|
||||
import { updateServerDiscoverability, updateServerFlags } from "@/lib/actions";
|
||||
import { useToast } from "../../ui/use-toast";
|
||||
import Link from "next/link";
|
||||
|
||||
export function ServerActions({ server }: { server: Server }) {
|
||||
const [selectBadges, setSelectBadges] = useState(false);
|
||||
|
@ -130,6 +128,13 @@ export function ServerActions({ server }: { server: Server }) {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Link
|
||||
className={`flex-1 ${buttonVariants()}`}
|
||||
href={`/panel/inspect/server/${server._id}/invites`}
|
||||
>
|
||||
Invites
|
||||
</Link>
|
||||
|
||||
<Button className="flex-1" variant="destructive">
|
||||
Quarantine
|
||||
</Button>
|
||||
|
|
|
@ -204,7 +204,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
)
|
||||
}
|
||||
>
|
||||
Suspend
|
||||
{userDraft.flags === 1 ? "Unsuspend" : "Suspend"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
@ -225,13 +225,18 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
<AlertDialogTitle>
|
||||
Are you sure you want to ban this user?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-red-700">
|
||||
This action is irreversible!
|
||||
<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!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="hover:bg-red-700 transition-all"
|
||||
onClick={() =>
|
||||
banUser(user._id)
|
||||
.then(() => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { insertAuditLog } from "./db";
|
||||
import { SafetyNotes, insertAuditLog } from "./db";
|
||||
|
||||
type Permission =
|
||||
| "authifier"
|
||||
|
@ -7,8 +7,8 @@ type Permission =
|
|||
| "chat_message"
|
||||
| `accounts${
|
||||
| ""
|
||||
| `/fetch${"" | "/by-id"}`
|
||||
| "/update/email"
|
||||
| `/fetch${"" | "/by-id" | "/by-email"}`
|
||||
| `/update${"" | "/email" | "/mfa"}`
|
||||
| "/disable"
|
||||
| "/restore"
|
||||
| `/deletion${"" | "/queue" | "/cancel"}`}`
|
||||
|
@ -16,7 +16,7 @@ type Permission =
|
|||
| ""
|
||||
| `/fetch${"" | "/by-id" | "/by-user"}`
|
||||
| `/update${"" | "/discoverability"}`}`
|
||||
| `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"}`}`
|
||||
| `reports${
|
||||
| ""
|
||||
|
@ -49,7 +49,11 @@ type Permission =
|
|||
| "/relations"}`
|
||||
| `/create${"" | "/alert" | "/strike"}`
|
||||
| `/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"]}`}`}`;
|
||||
|
||||
const PermissionSets = {
|
||||
// Admin
|
||||
|
@ -65,6 +69,7 @@ const PermissionSets = {
|
|||
"sessions",
|
||||
"servers",
|
||||
"users",
|
||||
"safety_notes",
|
||||
] as Permission[],
|
||||
|
||||
// View open reports
|
||||
|
@ -88,9 +93,16 @@ const PermissionSets = {
|
|||
"revolt-discover": [
|
||||
"servers/fetch/by-id",
|
||||
"servers/update/discoverability",
|
||||
"servers/update/flags",
|
||||
|
||||
"bots/fetch/by-id",
|
||||
"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[],
|
||||
|
||||
// User support
|
||||
|
@ -101,11 +113,19 @@ const PermissionSets = {
|
|||
"users/update/badges",
|
||||
|
||||
"accounts/fetch/by-id",
|
||||
"accounts/fetch/by-email",
|
||||
"accounts/disable",
|
||||
"accounts/restore",
|
||||
"accounts/deletion/queue",
|
||||
"accounts/deletion/cancel",
|
||||
"accounts/update/email",
|
||||
"accounts/update/mfa",
|
||||
|
||||
"channels/update/invites",
|
||||
"channels/fetch/invites",
|
||||
|
||||
"safety_notes/fetch",
|
||||
"safety_notes/update",
|
||||
] as Permission[],
|
||||
|
||||
// Moderate users
|
||||
|
@ -122,6 +142,7 @@ const PermissionSets = {
|
|||
"messages/fetch/by-id",
|
||||
"channels/fetch/by-id",
|
||||
"channels/fetch/dm",
|
||||
"channels/fetch/invites",
|
||||
"channels/create/dm",
|
||||
|
||||
"reports/fetch/related/by-user",
|
||||
|
@ -140,6 +161,9 @@ const PermissionSets = {
|
|||
|
||||
"publish_message",
|
||||
"chat_message",
|
||||
|
||||
"safety_notes/fetch",
|
||||
"safety_notes/update",
|
||||
] as Permission[],
|
||||
};
|
||||
|
||||
|
|
121
lib/actions.ts
121
lib/actions.ts
|
@ -4,12 +4,9 @@ import { writeFile } from "fs/promises";
|
|||
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
|
||||
import mongo, {
|
||||
Account,
|
||||
ChannelInvite,
|
||||
createDM,
|
||||
fetchAccountById,
|
||||
fetchChannels,
|
||||
fetchMembershipsByUser,
|
||||
fetchMessages,
|
||||
fetchUserById,
|
||||
findDM,
|
||||
} from "./db";
|
||||
import { publishMessage, sendChatMessage } from "./redis";
|
||||
|
@ -194,6 +191,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) {
|
||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||
await checkPermission("accounts/update/email", userId);
|
||||
|
@ -249,6 +280,22 @@ 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) {
|
||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||
|
||||
|
@ -269,7 +316,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)) {
|
||||
await publishMessage(topic, {
|
||||
type: "UserUpdate",
|
||||
|
@ -536,6 +588,65 @@ export async function updateServerDiscoverability(
|
|||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
await checkPermission("bots/update/discoverability", botId, { state });
|
||||
await mongo()
|
||||
|
|
105
lib/db.ts
105
lib/db.ts
|
@ -1,10 +1,11 @@
|
|||
"use server";
|
||||
|
||||
import { Filter, MongoClient } from "mongodb";
|
||||
import { Filter, MongoClient, WithId } from "mongodb";
|
||||
import type {
|
||||
AccountStrike,
|
||||
Bot,
|
||||
Channel,
|
||||
Invite,
|
||||
Message,
|
||||
Report,
|
||||
Server,
|
||||
|
@ -104,6 +105,12 @@ export type Account = {
|
|||
attempts: number;
|
||||
expiry: string;
|
||||
};
|
||||
mfa?: {
|
||||
totp_token?: {
|
||||
status: "Pending" | "Enabled";
|
||||
};
|
||||
recovery_codes?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchAccountById(id: string) {
|
||||
|
@ -112,15 +119,31 @@ export async function fetchAccountById(id: string) {
|
|||
return await mongo()
|
||||
.db("revolt")
|
||||
.collection<Account>("accounts")
|
||||
.findOne(
|
||||
{ _id: id },
|
||||
{
|
||||
projection: {
|
||||
password: 0,
|
||||
mfa: 0,
|
||||
.aggregate(
|
||||
[
|
||||
{
|
||||
$match: { _id: id },
|
||||
},
|
||||
}
|
||||
);
|
||||
{
|
||||
$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) {
|
||||
|
@ -264,6 +287,33 @@ export async function fetchServers(query: Filter<Server>) {
|
|||
.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) {
|
||||
await checkPermission("messages/fetch/by-id", id);
|
||||
|
||||
|
@ -510,3 +560,40 @@ export async function fetchAuthifierEmailClassification(provider: string) {
|
|||
.collection<EmailClassification>("email_classification")
|
||||
.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://futacockinside.me/files/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-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/gravatar": "^1.8.3",
|
||||
"@types/node": "20.4.4",
|
||||
"@types/react": "18.2.15",
|
||||
"@types/react-dom": "18.2.7",
|
||||
|
@ -29,15 +30,19 @@
|
|||
"dayjs": "^1.11.9",
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-next": "13.4.12",
|
||||
"gravatar": "^1.8.2",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.263.0",
|
||||
"mongodb": "^5.7.0",
|
||||
"next": "13.4.12",
|
||||
"next-auth": "^4.22.3",
|
||||
"ntfy": "^1.3.1",
|
||||
"postcss": "8.4.27",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"redis": "^4.6.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"revolt-api": "^0.6.5",
|
||||
"sass": "^1.64.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
@ -47,6 +52,7 @@
|
|||
"ulid": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"revolt.js": "7.0.0-beta.9"
|
||||
}
|
||||
|
|
969
pnpm-lock.yaml
969
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -38,3 +38,5 @@ app.prepare().then(() => {
|
|||
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