1
0
Fork 0

Compare commits

...

91 Commits
fix-1 ... main

Author SHA1 Message Date
Paul Makles 73ca6ec732 chore: add tom 2024-08-16 13:24:39 +00:00
Paul Makles e498e9d261 chore: mod perms 2024-07-24 10:15:00 +00:00
Paul Makles 78ce9f96d7 chore: update emails 2024-03-30 20:36:04 +00:00
Lea 96d5884461
feat: highlight suspended/banned users 2023-12-05 18:10:02 +01:00
Paul Makles b79a14f00c
chore: edit template responses to ask user to email 2023-11-30 13:28:55 +00:00
Paul Makles 7f1619d33a
chore: make cased reports go away 2023-11-26 13:23:00 +00:00
Paul Makles 5cdb02b5bf
refactor: rows not cols 2023-11-26 13:19:08 +00:00
Paul Makles 500f8b3e1c
htrdhytdjfytuuytjf 2023-11-26 13:17:18 +00:00
Paul Makles c30d75c0ed
chore: trolling 2023-11-26 13:16:30 +00:00
Paul Makles 8113a86db9
feat: wipe user messages 2023-11-26 13:14:44 +00:00
Paul Makles eb0bd7a7c9
feat: case system 2023-11-26 13:11:44 +00:00
Paul Makles e9b623f60d
feat: show username of grouped reports rather than id 2023-11-25 20:59:28 +00:00
Paul Makles cdb05a5af7
feat: overdue badge 2023-11-01 16:23:41 +00:00
Lea 221ce0e75d fix: remove recent messages panel 2023-11-01 17:18:28 +01:00
Lea 5b0bc0d36a feat: enable and improve relations/server list 2023-11-01 17:18:28 +01:00
Lea 20579c9ebb fix: fix size of images on landing page 2023-11-01 17:18:28 +01:00
Lea 386f124fe9 fix: reorder action buttons on small screens 2023-11-01 17:18:28 +01:00
Lea a3e5db0886 feat: search users by username or tag 2023-11-01 17:18:28 +01:00
Lea 9c531f3d99 Revert "feat: user stream UI"
i messed up and worked on the wrong branch :trollface:
2023-11-01 17:18:28 +01:00
Lea 543baee97f feat: warning banner on restricted users 2023-11-01 17:18:28 +01:00
Lea 0210d385aa fix: allow sending multiline alerts 2023-11-01 17:18:28 +01:00
Lea bc48204410 feat: transfer bots, reset bot token
also slightly adjusted ACLs
2023-11-01 17:18:28 +01:00
Lea 77075dad23 feat: user stream UI 2023-11-01 17:18:28 +01:00
Lea 54aea181fa chore: add vscode configuration 2023-11-01 17:18:28 +01:00
Paul Makles bbaff35812 feat: group reports by author if not urgent 2023-10-26 00:20:19 +01:00
Paul Makles 8246232461
fix: sort emails 2023-09-03 12:25:45 +01:00
Paul Makles 64671be091
chore: add proper list for classifications 2023-09-03 12:21:19 +01:00
Lea c54c690fe4 fix: optimise CSS 2023-09-03 12:52:49 +02:00
Lea 373c66a251 feat: add functionality to classification page 2023-09-03 12:52:49 +02:00
Lea fc51faf9e1 feat: email classification UI 2023-09-03 12:52:49 +02:00
Lea 6afb040ffd
fix: send alerts in chunks 2023-08-26 21:12:21 +02:00
Lea da3ae73c1e
fix: don't check perms for every quarantine alert 2023-08-26 20:57:43 +02:00
Lea f6c2bd1e96
fix: parallelise sending of quarantine alerts 2023-08-26 20:51:51 +02:00
Lea df9e8ed8d4
fix: fix the oopsie 2023-08-25 20:25:00 +02:00
Paul Makles 3f73a5abb9
feat: add .env.example 2023-08-25 18:36:41 +01:00
Paul Makles cd4e01d9c6
chore: format file 2023-08-25 18:36:31 +01:00
Paul Makles a748a68ba7
feat: add nix file 2023-08-25 18:36:04 +01:00
Lea d02d88c073
fix: put attention.png in public directory 2023-08-25 13:23:14 +02:00
Lea 20b889aca9
fix: serve backups as json file 2023-08-25 13:21:43 +02:00
Lea 9122eddb7c
chore: add .pnpm-store to .gitignore 2023-08-25 12:54:26 +02:00
Lea 84b0eec069
fix: add missing permission check 2023-08-25 09:16:36 +02:00
Lea d1ff57b239
feat: add backup list/viewer 2023-08-22 18:29:48 +02:00
Lea a03c539890
feat: quarantine button 2023-08-22 18:02:56 +02:00
Lea d053e8ff5c
feat: show account/server/channel age 2023-08-21 16:27:20 +02:00
Lea 84d594ad0b
fix: show commit hash and branch instead of v0.0.1 2023-08-20 15:56:59 +02:00
Lea 3f5ec1f2ef
feat: user selector component 2023-08-20 15:33:31 +02:00
Lea 7d7017168b
fix: align server/user/channel icon with title 2023-08-20 13:47:50 +02:00
Lea c3f18cec9b
feat: allow adding users to a server 2023-08-20 13:40:11 +02:00
Lea cf71aa49cb
feat: button to change server owner 2023-08-20 12:50:39 +02:00
Lea 83161623e3
fix: fix width on markdown boxes 2023-08-17 17:03:29 +02:00
Lea 04bc95c1c3
fix: add bulk close permission to moderators 2023-08-17 16:55:36 +02:00
Lea 239f667fe0
feat: add ntfy.sh notifications 2023-08-17 16:41:17 +02:00
Lea edd1f9d297 feat: render notes as markdown 2023-08-12 14:44:42 +02:00
Lea 03fffc9849 feat: object notes 2023-08-12 14:44:42 +02:00
Lea 6cfe0a2ffa fix: suspending works again, clarify bans 2023-08-12 14:44:42 +02:00
Lea 72db810066 feat: allow creating and editing vanity invites 2023-08-12 14:44:42 +02:00
Lea 91ba9b94c8 feat: bulk delete invites 2023-08-12 14:44:42 +02:00
Lea 977986736b feat: allow invite editing and deleting 2023-08-12 14:44:42 +02:00
Lea a04a10f492 feat: server invite list 2023-08-12 14:44:42 +02:00
Lea 9249b4e58d fix: discover role should have access to set flags 2023-08-12 14:44:42 +02:00
Lea e575389a66 feat: MFA management 2023-08-12 14:44:42 +02:00
Lea 7ba9565df7 feat: link to user page from account page 2023-08-12 14:44:42 +02:00
Lea 3a2e886803 feat: look up user by email 2023-08-12 14:44:42 +02:00
Lea 5c78020a3e feat: add pizza 2023-08-12 14:44:42 +02:00
Lea e8e0ef11d3 feat: use gravatar 2023-08-12 14:44:42 +02:00
Lea 8989abddec chore: disable annoying lint rules 2023-08-12 14:44:42 +02:00
Lea 5f774a0b72 Merge pull request 'feat: new favicon' (#12) from infi/administration-panel:main into main
Reviewed-on: administration/panel#12
2023-08-11 16:31:54 +02:00
Jennifer 0237f9808a feat: new favicon 2023-08-11 16:11:07 +02:00
Paul Makles e1fd192f8b
chore: unset display name 2023-08-10 12:31:23 +01:00
Paul Makles 1d8bc5f301
fix: remove unnecessary toast description 2023-08-10 12:30:27 +01:00
Paul Makles a896884593
chore: reduce permissions required to wipe / update badges 2023-08-10 12:29:54 +01:00
Paul Makles 70133ff82b
chore: port email normalisation routine 2023-08-10 12:26:36 +01:00
Paul Makles 626b490912
refactor: clean up recent messages fetch 2023-08-10 12:26:17 +01:00
Paul Makles 87c0fab107
chore: update max JWT age to 2 hours
closes #2
2023-08-10 12:25:03 +01:00
Lea 991cbef040
feat: link to author in message snapshot view 2023-08-10 00:03:16 +02:00
Lea 80ba4b7d77
add email verification actions 2023-08-09 23:57:16 +02:00
Lea 1349951135
feat: add button to change a user's email 2023-08-09 23:28:11 +02:00
Lea 4f6be05162
feat: make profile wipe functional 2023-08-09 22:56:28 +02:00
Lea 78632d4a48
fix: clear alert message after sending 2023-08-09 22:26:10 +02:00
Lea 1f116d4e37
chore: goot bye temp 2023-08-09 22:23:49 +02:00
Lea e5d2a98af9
fix: reword button text 2023-08-09 22:21:01 +02:00
Lea 9ec4fe1b3a
feat: checkbox element, reset profile dialog 2023-08-09 22:18:52 +02:00
Lea bf77f3798e
chore: allow mods to access user bots and servers
i don't think this info is too sensitive, i intentionally left out messages and memberships for now
2023-08-09 21:20:45 +02:00
Lea 831abb6dc2
fix: fetch created reports correctly 2023-08-09 21:13:15 +02:00
Lea de305d3901
chore: update ACLs
- Added permission to manage badges for user support (mainly to assign the donator badge)
- Added `channels/{fetch,create}/dm` for moderation, required for alerts
- Removed permission bypass for insert since it shouldn't be needed anymore
2023-08-09 21:12:54 +02:00
Lea 2522cfe6de
don't error if publishing badge update failed 2023-08-09 21:01:38 +02:00
Lea 19465043a1
fix: send correct content when sending alert 2023-08-09 20:46:25 +02:00
Lea 2cf61f26a0
feat: 🐝 2023-08-09 20:35:25 +02:00
Lea 0cbe4933f8
fix: render account page without `authifier` perm 2023-08-09 20:19:28 +02:00
Lea 3104c57db7
fix: catch errors while fetching recent messages 2023-08-09 20:15:32 +02:00
Lea d4de52dc35
feat: allow permission bypass via env var 2023-08-09 20:02:50 +02:00
61 changed files with 4577 additions and 234 deletions

24
.env.example Normal file
View File

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

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

2
.gitignore vendored
View File

@ -37,3 +37,5 @@ next-env.d.ts
# data # data
exports/** exports/**
!exports/.gitkeep !exports/.gitkeep
.pnpm-store

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"editor.insertSpaces": true
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

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

37
app/panel/cases/page.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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-2"> <div className="flex flex-col gap-8">
<Input placeholder="Search for reports..." disabled /> {/*<Input placeholder="Search for reports..." disabled />*/}
{reports.map((report) => ( {reports.length ? (
<CardLink key={report._id} href={`/panel/reports/${report._id}`}> keyOrder
<ReportCard report={report} /> .filter((key) => byCategory[key].length)
</CardLink> .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&lsquo;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">
{byCategory["AssignedToCase"].map((report) => (
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
<ReportCard report={report} />
</CardLink>
))}
</div>
</details>
)}
</div> </div>
); );
} }

View File

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

14
app/panel/shield/page.tsx Normal file
View File

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

View File

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

View File

@ -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)} &middot;{" "}
{dayjs(decodeTime(entry._id)).fromNow()} &middot; {entry.author}{" "}
{entry.status !== "Open" && entry.closed_at && (
<>&middot; Closed {dayjs(entry.closed_at).fromNow()}</>
)}
</CardDescription>
</CardHeader>
</Card>
);
}

View File

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

View File

@ -112,6 +112,9 @@ export function CompactMessage({
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<Link href={`/panel/inspect/user/${message.author}`}>
<AlertDialogCancel>Author</AlertDialogCancel>
</Link>
<Link href={`/panel/inspect/message/${message._id}`}> <Link href={`/panel/inspect/message/${message._id}`}>
<AlertDialogCancel>Inspect</AlertDialogCancel> <AlertDialogCancel>Inspect</AlertDialogCancel>
</Link> </Link>

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

@ -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 && (
<>&middot; Closed {dayjs(report.closed_at).fromNow()}</> <>&middot; Closed {dayjs(report.closed_at).fromNow()}</>
)}{" "}
{report.case_id && (
<>
&middot;{" "}
<Badge className="align-middle" variant="secondary">
Assigned
</Badge>
</>
)}{" "}
{report.status === "Created" && decodeTime(report._id) < dueDate && (
<>
&middot;{" "}
<Badge className="align-middle" variant="relatively-destructive">
Due{" "}
{dayjs()
.add(dayjs(decodeTime(report._id)).diff(dueDate))
.fromNow()}
</Badge>
</>
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>

View File

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

View File

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

View File

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

View File

@ -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.username}#{user.discriminator} {user.display_name} {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}
{
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>

View File

@ -8,7 +8,7 @@ import { fetchAuthifierEmailClassification } from "@/lib/db";
export async function EmailClassificationCard({ email }: { email: string }) { export async function EmailClassificationCard({ email }: { email: string }) {
const provider = email.split("@").pop() ?? ""; const provider = email.split("@").pop() ?? "";
const providerInfo = await fetchAuthifierEmailClassification(provider); const providerInfo = await fetchAuthifierEmailClassification(provider).catch(() => null);
if (!providerInfo) return null; if (!providerInfo) return null;
return ( return (

View File

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

View File

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

View File

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

View File

@ -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]"
/> />
</> </>
); );

View File

@ -7,9 +7,13 @@ import type { Account } from "@/lib/db";
import { User } from "revolt-api"; import { User } from "revolt-api";
import { import {
cancelAccountDeletion, cancelAccountDeletion,
changeAccountEmail,
deleteMFARecoveryCodes,
disableAccount, disableAccount,
disableMFA,
queueAccountDeletion, queueAccountDeletion,
restoreAccount, restoreAccount,
verifyAccountEmail,
} from "@/lib/actions"; } from "@/lib/actions";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -24,6 +28,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "../../ui/alert-dialog"; } from "../../ui/alert-dialog";
import { AlertDialogDescription } from "@radix-ui/react-alert-dialog";
import { Input } from "@/components/ui/input";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export function AccountActions({ export function AccountActions({
@ -36,9 +42,175 @@ export function AccountActions({
const { toast } = useToast(); const { toast } = useToast();
const [accountDraft, setAccountDraft] = useState(account); const [accountDraft, setAccountDraft] = useState(account);
const [emailDraft, setEmailDraft] = useState("");
return ( return (
<div className="flex gap-2"> <div className="flex flex-col md:flex-row gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="flex-1">
Change Email
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Update account email
</AlertDialogTitle>
<AlertDialogDescription>
<Input
placeholder={account.email}
onChange={(e) => setEmailDraft(e.currentTarget.value)}
value={emailDraft}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!/^.+@.+$/.test(emailDraft)}
onClick={async () => {
try {
await changeAccountEmail(account._id, emailDraft);
setEmailDraft("");
toast({ title: "Updated email" });
window.location.reload();
} catch (err) {
toast({
title: "Failed to execute action",
description: String(err),
variant: "destructive",
});
}
}}
>
Change
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="flex-1"
disabled={accountDraft.verification.status == "Verified"}
>
{accountDraft.verification.status == "Verified" ? "Email is verified" : "Verify email"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Mark Email as verified
</AlertDialogTitle>
<AlertDialogDescription>
Verification status is currently {accountDraft.verification.status}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
try {
await verifyAccountEmail(account._id);
toast({ title: "Verified email" });
setAccountDraft({ ...accountDraft, verification: { status: "Verified" } });
} catch(e) {
toast({
title: "Failed to verify",
description: String(e),
variant: "destructive",
})
}
}}
>
Mark verified
</AlertDialogAction>
<AlertDialogAction
disabled={!(accountDraft.verification.status != "Verified" && accountDraft.verification.token)}
onClick={() => {
navigator.clipboard.writeText(`https://app.revolt.chat/login/verify/${(accountDraft.verification as any).token}`);
toast({ title: "Copied verification link" })
}}
>
Copy link
</AlertDialogAction>
</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> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button

View File

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

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,4 +1,4 @@
import type { Filter } from "mongodb"; import type { Filter, WithId } from "mongodb";
import { Message, User } from "revolt-api"; import { Message, User } from "revolt-api";
import { import {
Card, Card,
@ -20,12 +20,11 @@ type Props = { users?: boolean | User[] } & (
); );
export async function RecentMessages(props: Props) { export async function RecentMessages(props: Props) {
const recentMessages = ( let recentMessages = (
(props as { query: {} }).query await ((props as { query: {} }).query
? await fetchMessages((props as { query: Filter<Message> }).query) ? fetchMessages((props as { query: Filter<Message> }).query)
: await fetchMessagesByUser((props as { userId: string }).userId).catch( : fetchMessagesByUser((props as { userId: string }).userId)
() => [] ).catch(() => [])
)
).reverse(); ).reverse();
const userList = ( const userList = (

View File

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

View File

@ -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" ? (
<> <>

View File

@ -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>
<Button className="flex-1" variant="destructive"> <Link
Quarantine className={`flex-1 ${buttonVariants()}`}
</Button> href={`/panel/inspect/server/${server._id}/invites`}
>
Invites
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="flex-1" variant="destructive">
Quarantine
</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>
); );
} }

View File

@ -23,11 +23,15 @@ 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,
} from "@/lib/actions"; } from "@/lib/actions";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useToast } from "../../ui/use-toast"; import { useToast } from "../../ui/use-toast";
@ -35,6 +39,9 @@ import { Bot, User } from "revolt-api";
import { Card, CardHeader } from "../../ui/card"; 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 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];
@ -44,6 +51,15 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
const [userDraft, setUserDraft] = useState(user); const [userDraft, setUserDraft] = useState(user);
const [botDraft, setBotDraft] = useState(bot); const [botDraft, setBotDraft] = useState(bot);
const [wipeDraft, setWipeDraft] = useState({
banner: false,
avatar: false,
bio: false,
displayName: 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;
@ -94,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
@ -195,7 +211,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
) )
} }
> >
Suspend {userDraft.flags === 1 ? "Unsuspend" : "Suspend"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -216,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>
This action is irreversible! 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> </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(() => {
@ -244,6 +265,71 @@ 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&apos;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>
<AlertDialogTrigger asChild>
<Button className="flex-1 bg-yellow-600">Bees</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Release the bees</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to send the bees?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => toast({ title: "🐝" })}>
Deploy
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="flex-1"> <Button variant="outline" className="flex-1">
@ -263,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) =>
@ -277,10 +363,12 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
if (!alertMessage.current) return; if (!alertMessage.current) return;
alertMessage.current = "";
sendAlert(user._id, alertMessage.current) sendAlert(user._id, alertMessage.current)
.then(() => toast({ title: "Sent Alert" })) .then(() => {
toast({ title: "Sent Alert" });
alertMessage.current = "";
})
.catch((err) => .catch((err) =>
toast({ toast({
title: "Failed to send alert!", title: "Failed to send alert!",
@ -296,6 +384,185 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">Reset profile</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset user profile</AlertDialogTitle>
<AlertDialogDescription>
<Checkbox
checked={wipeDraft.avatar}
onChange={(e) =>
setWipeDraft({ ...wipeDraft, avatar: e == true })
}
>
Avatar
</Checkbox>
<Checkbox
checked={wipeDraft.banner}
onChange={(e) =>
setWipeDraft({ ...wipeDraft, banner: e == true })
}
>
Profile Banner
</Checkbox>
<Checkbox
checked={wipeDraft.displayName}
onChange={(e) =>
setWipeDraft({ ...wipeDraft, displayName: e == true })
}
>
Display Name
</Checkbox>
<Checkbox
checked={wipeDraft.bio}
onChange={(e) =>
setWipeDraft({ ...wipeDraft, bio: e == true })
}
>
Bio
</Checkbox>
<Checkbox
checked={wipeDraft.status}
onChange={(e) =>
setWipeDraft({ ...wipeDraft, status: e == true })
}
>
Status
</Checkbox>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!Object.values(wipeDraft).filter((i) => i).length}
onClick={() => {
wipeUserProfile(user._id, wipeDraft)
.then(() => {
toast({ title: "Wiped selected fields" });
window.location.reload();
})
.catch((e) =>
toast({
title: "Failed to wipe profile",
description: String(e),
variant: "destructive",
})
)
.finally(() =>
setWipeDraft({
avatar: false,
banner: false,
bio: false,
displayName: false,
status: false,
})
);
}}
>
Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</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&apos;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>

View File

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

View File

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

View File

@ -0,0 +1,46 @@
"use client"
import { cn } from '@/lib/utils';
import * as CheckBox from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
const Checkbox = (props: {
children: React.ReactNode,
checked?: boolean,
onChange?: (checked: CheckBox.CheckedState) => void,
}) => {
// good enough i guess
const checkId = `${Date.now()}${Math.random()}`;
return (
<div className={cn('flex', 'items-center', 'm-1')}>
<CheckBox.Root
checked={props.checked}
onCheckedChange={props.onChange}
id={checkId}
className={cn(
'flex',
'rounded-md',
'border-gray-300',
'border',
'w-6',
'h-6',
'mr-2',
'items-center',
'justify-center',
'bg-slate-100',
'shadow-lg',
)}
>
<CheckBox.Indicator
className={cn('text-gray-500')}
>
<CheckIcon />
</CheckBox.Indicator>
</CheckBox.Root>
<label htmlFor={checkId}>{props.children}</label>
</div>
);
}
export { Checkbox }

View File

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

9
default.nix Normal file
View File

@ -0,0 +1,9 @@
with import <nixpkgs> { };
pkgs.mkShell {
name = "adminEnv";
buildInputs = [
pkgs.git
pkgs.nodejs
pkgs.nodePackages.pnpm
];
}

View File

@ -1,54 +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" | "/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"}`}`; | `/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
@ -64,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",
@ -80,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
@ -97,36 +149,68 @@ const PermissionSets = {
"users/fetch/by-id", "users/fetch/by-id",
"users/fetch/strikes", "users/fetch/strikes",
"users/fetch/notices", "users/fetch/notices",
"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/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",
// "servers/fetch", "users/fetch/relations",
"servers/fetch",
"messages/fetch/by-id", "messages/fetch/by-id",
"channels/fetch/by-id", "channels/fetch/by-id",
"channels/fetch/dm",
"channels/fetch/invites",
"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",
"users/action/suspend", "users/action/suspend",
"users/action/wipe", "users/action/wipe",
"users/action/wipe-profile",
"users/action/ban", "users/action/ban",
"users/action/unsuspend", "users/action/unsuspend",
"accounts/disable", "accounts/disable",
@ -134,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 = {
@ -142,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"]],
@ -154,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"],
@ -177,7 +277,7 @@ const ACL: Record<string, Set<Permission>> = {
}; };
function hasPermission(email: string, permission: Permission) { function hasPermission(email: string, permission: Permission) {
if (email === "insert@revolt.chat") return true; if (process.env.BYPASS_ACL) return true;
if (!ACL[email]) throw `user is not registered in system: ${email}`; if (!ACL[email]) throw `user is not registered in system: ${email}`;
const segments = permission.split("/"); const segments = permission.split("/");
@ -206,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);
} }

View File

@ -1,14 +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,
fetchChannels, fetchAccountById,
fetchMembershipsByUser,
fetchMessages,
fetchUserById,
findDM, findDM,
} from "./db"; } from "./db";
import { publishMessage, sendChatMessage } from "./redis"; import { publishMessage, sendChatMessage } from "./redis";
@ -17,7 +18,9 @@ import {
AccountInfo, AccountInfo,
AccountStrike, AccountStrike,
Bot, Bot,
Channel,
File, File,
Invite,
Member, Member,
Message, Message,
Report, Report,
@ -26,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 });
@ -95,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);
@ -113,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 });
@ -155,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);
@ -192,6 +274,109 @@ 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);
const SPLIT_RE = /([^@]+)(@.+)/;
const SYMBOL_RE = /\+.+|\./g;
const segments = SPLIT_RE.exec(email);
if (!segments) throw "invalid email";
await mongo()
.db("revolt")
.collection<Account>("accounts")
.updateOne(
{ _id: userId },
{
$set: {
email: email,
email_normalised: segments[1].replace(SYMBOL_RE, "") + segments[2],
verification: { status: "Verified" },
},
}
);
}
export async function verifyAccountEmail(userId: string) {
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
await checkPermission("accounts/update/email", userId);
const account = await fetchAccountById(userId);
if (!account) throw new Error("couldn't find account");
if (account.verification.status == "Verified")
throw new Error("already verified");
let email = account.email;
if (account.verification.status == "Moving") {
email = account.verification.new_email;
}
await mongo()
.db("revolt")
.collection<Account>("accounts")
.updateOne(
{ _id: userId },
{
$set: {
email: email,
email_normalised: email, // <-- should be fine but someone should fix this in the future
verification: { status: "Verified" },
},
}
);
}
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";
@ -212,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",
@ -238,7 +428,12 @@ export async function updateUserBadges(userId: string, badges: number) {
} }
); );
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 [userId, ...memberships.map((x) => x._id.server)]) { for (const topic of [userId, ...memberships.map((x) => x._id.server)]) {
await publishMessage(topic, { await publishMessage(topic, {
type: "UserUpdate", type: "UserUpdate",
@ -251,21 +446,54 @@ 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 = onlyMessages
? null
: await mongo()
.db("revolt")
.collection<User>("users")
.findOne({ _id: userId });
const messages = await mongo()
.db("revolt")
.collection<Message>("messages")
.find({ author: userId }, { sort: { _id: -1 } })
.toArray();
const dms = onlyMessages
? null
: await mongo()
.db("revolt")
.collection<Channel>("channels")
.find({
channel_type: "DirectMessage",
recipients: userId,
})
.toArray();
const memberships = onlyMessages
? null
: await mongo()
.db("revolt")
.collection<{ _id: { user: string; server: string } }>("server_members")
.find({ "_id.user": userId })
.toArray();
// retrieve messages, dm channels, relationships, server memberships // retrieve messages, dm channels, relationships, server memberships
const backup = { const backup = {
_event: "wipe", _event: onlyMessages ? "messages" : "wipe",
user: await fetchUserById(userId), user,
messages: await fetchMessages({ author: userId }, undefined), messages,
dms: await fetchChannels({ dms,
channel_type: "DirectMessage", memberships,
recipients: userId,
}),
memberships: await fetchMembershipsByUser(userId),
}; };
await writeFile( await writeFile(
@ -281,12 +509,14 @@ export async function wipeUser(userId: string, flags = 4) {
.filter((attachment) => attachment) .filter((attachment) => attachment)
.map((attachment) => attachment!._id); .map((attachment) => attachment!._id);
if (backup.user?.avatar) { if (!onlyMessages) {
attachmentIds.push(backup.user.avatar._id); if (backup.user?.avatar) {
} attachmentIds.push(backup.user.avatar._id);
}
if (backup.user?.profile?.background) { if (backup.user?.profile?.background) {
attachmentIds.push(backup.user.profile.background._id); attachmentIds.push(backup.user.profile.background._id);
}
} }
if (attachmentIds.length) { if (attachmentIds.length) {
@ -309,44 +539,46 @@ export async function wipeUser(userId: string, flags = 4) {
author: userId, author: userId,
}); });
// delete server memberships if (!onlyMessages) {
await mongo().db("revolt").collection<Member>("server_members").deleteMany({ // delete server memberships
"_id.user": userId, await mongo().db("revolt").collection<Member>("server_members").deleteMany({
}); "_id.user": userId,
// disable account
await disableAccount(userId);
// clear user profile
await mongo()
.db("revolt")
.collection<User>("users")
.updateOne(
{
_id: userId,
},
{
$set: {
flags,
},
$unset: {
avatar: 1,
profile: 1,
status: 1,
},
}
);
// broadcast wipe event
for (const topic of [
...backup.dms.map((x) => x._id),
...backup.memberships.map((x) => x._id.server),
]) {
await publishMessage(topic, {
type: "UserPlatformWipe",
user_id: userId,
flags,
}); });
// disable account
await disableAccount(userId);
// clear user profile
await mongo()
.db("revolt")
.collection<User>("users")
.updateOne(
{
_id: userId,
},
{
$set: {
flags,
},
$unset: {
avatar: 1,
profile: 1,
status: 1,
},
}
);
// broadcast wipe event
for (const topic of [
...backup.dms!.map((x) => x._id),
...backup.memberships!.map((x) => x._id.server),
]) {
await publishMessage(topic, {
type: "UserPlatformWipe",
user_id: userId,
flags,
});
}
} }
} }
@ -376,6 +608,37 @@ export async function unsuspendUser(userId: string) {
); );
} }
export async function wipeUserProfile(
userId: string,
fields: {
banner: boolean;
avatar: boolean;
bio: boolean;
displayName: boolean;
status: boolean;
}
) {
await checkPermission("users/action/wipe-profile", userId);
await mongo()
.db("revolt")
.collection<User>("users")
.updateOne(
{
_id: userId,
},
{
$unset: {
...(fields.banner ? { "profile.background": 1 } : {}),
...(fields.bio ? { "profile.content": 1 } : {}),
...(fields.status ? { "status.text": 1 } : {}),
...(fields.avatar ? { avatar: 1 } : {}),
...(fields.displayName ? { display_name: 1 } : {}),
},
}
);
}
export async function updateServerFlags(serverId: string, flags: number) { export async function updateServerFlags(serverId: string, flags: number) {
await checkPermission("servers/update/flags", serverId, { flags }); await checkPermission("servers/update/flags", serverId, { flags });
await mongo().db("revolt").collection<Server>("servers").updateOne( await mongo().db("revolt").collection<Server>("servers").updateOne(
@ -420,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()
@ -438,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);
@ -500,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();
}

View File

@ -11,6 +11,9 @@ export const authOptions: AuthOptions = {
"https://sso.revolt.chat/application/o/admin-panel", "https://sso.revolt.chat/application/o/admin-panel",
}), }),
], ],
jwt: {
maxAge: 2 * 60 * 60, // 2 hours
},
pages: { pages: {
signIn: "/", signIn: "/",
}, },

View File

@ -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 =
@ -12,6 +14,7 @@ export const RESTRICT_ACCESS_LIST = [
"01FD58YK5W7QRV5H3D64KTQYX3", //- zoma "01FD58YK5W7QRV5H3D64KTQYX3", //- zoma
"01F1WKM5TK2V6KCZWR6DGBJDTZ", //- infi "01F1WKM5TK2V6KCZWR6DGBJDTZ", //- infi
"01EXAHMSGNDCAZTJXDJZ0BK8N3", //- wait what "01EXAHMSGNDCAZTJXDJZ0BK8N3", //- wait what
"01GRGF2J5BRW9MVN6RS4V3X2DR", //- temp
"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
View File

@ -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 },
},
{
$project: {
password: 0, password: 0,
mfa: 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);
@ -344,7 +425,24 @@ export async function fetchReportsByUser(userId: string) {
.db("revolt") .db("revolt")
.collection<Report>("safety_reports") .collection<Report>("safety_reports")
.find( .find(
{ status: "Created", authorId: userId }, { author_id: userId },
{
sort: {
_id: -1,
},
}
)
.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: { sort: {
_id: -1, _id: -1,
@ -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 }
);
}

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

View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.0.4", "@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
@ -18,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",
@ -28,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",
@ -46,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"
} }

File diff suppressed because it is too large Load Diff

BIN
public/attention.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -38,3 +38,5 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`); 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")],
} }