1
0
Fork 0

Compare commits

...

15 Commits

26 changed files with 1989 additions and 109 deletions

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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 />

View File

@ -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" />

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>;
}

View File

@ -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&lsquo;ve caught up for now.
</h3>
</>)
}
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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(() => {

View File

@ -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[],
};

View File

@ -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
View File

@ -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 },
);
}

56
notifications.mjs Normal file
View File

@ -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();
}

View File

@ -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"
}

File diff suppressed because it is too large Load Diff

View File

@ -38,3 +38,5 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
import('./notifications.mjs');

View File

@ -72,5 +72,5 @@ module.exports = {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
}