forked from administration/panel
Compare commits
2 Commits
main
...
user-strea
Author | SHA1 | Date |
---|---|---|
|
b80ae05820 | |
|
7c22a25447 |
|
@ -1,41 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -24,7 +24,6 @@ import { User } from "revolt-api";
|
|||
import { decodeTime } from "ulid";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
|
||||
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
|
@ -42,8 +41,6 @@ export default async function User({
|
|||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<NavigationToolbar>Inspecting Account</NavigationToolbar>
|
||||
|
||||
<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} />
|
||||
<EmailClassificationCard email={account.email} />
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { lookupEmail, searchUserByTag } from "@/lib/actions";
|
||||
import { lookupEmail } from "@/lib/actions";
|
||||
import { API_URL } from "@/lib/constants";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
@ -11,8 +11,6 @@ import { useState } from "react";
|
|||
export default function Inspect() {
|
||||
const [id, setId] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [discriminator, setDiscriminator] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const searchEmail = async () => {
|
||||
|
@ -33,29 +31,6 @@ export default function Inspect() {
|
|||
}
|
||||
};
|
||||
|
||||
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) => () =>
|
||||
router.push(`/panel/inspect/${type}/${id}`);
|
||||
|
||||
|
@ -66,7 +41,7 @@ export default function Inspect() {
|
|||
value={id}
|
||||
onChange={(e) => setId(e.currentTarget.value)}
|
||||
/>
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
|
@ -117,50 +92,22 @@ export default function Inspect() {
|
|||
</Button>
|
||||
</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 className="flex gap-2 justify-between">
|
||||
<Input
|
||||
placeholder="Enter an email..."
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key == "Enter" && email && searchEmail()}
|
||||
/>
|
||||
<Button
|
||||
className="flex"
|
||||
variant="outline"
|
||||
disabled={!email}
|
||||
onClick={searchEmail}
|
||||
>
|
||||
Lookup
|
||||
</Button>
|
||||
</div>
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -30,7 +30,6 @@ import { notFound } from "next/navigation";
|
|||
import { Bot } from "revolt-api";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { decodeTime } from "ulid";
|
||||
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
|
@ -57,10 +56,12 @@ export default async function User({
|
|||
const relevantUsers = await fetchUsersById([
|
||||
...botIds,
|
||||
...(
|
||||
user.relations ?? []
|
||||
user.relations?.filter((relation) => relation.status === "Friend") ?? []
|
||||
).map((relation) => relation._id),
|
||||
]);
|
||||
|
||||
relevantUsers.sort((a) => (a.bot ? -1 : 0));
|
||||
|
||||
// Fetch server memberships
|
||||
const serverMemberships = await fetchMembershipsByUser(user._id).catch(
|
||||
() => []
|
||||
|
@ -79,7 +80,6 @@ export default async function User({
|
|||
<div className="flex flex-col gap-2">
|
||||
<NavigationToolbar>Inspecting User</NavigationToolbar>
|
||||
|
||||
<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} />
|
||||
<SafetyNotesCard objectId={user._id} type="user" />
|
||||
|
@ -115,6 +115,9 @@ export default async function User({
|
|||
<Separator />
|
||||
<RelevantReports byUser={reportsByUser} forUser={reportsAgainstUser} />
|
||||
|
||||
<Separator />
|
||||
<RecentMessages userId={user._id} />
|
||||
|
||||
<Separator />
|
||||
<JsonCard obj={user} />
|
||||
</div>
|
||||
|
|
|
@ -1,101 +1,32 @@
|
|||
import { ReportCard } from "@/components/cards/ReportCard";
|
||||
import { CardLink } from "@/components/common/CardLink";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchOpenReports, fetchUsersById } from "@/lib/db";
|
||||
import { fetchOpenReports } from "@/lib/db";
|
||||
import { PizzaIcon } from "lucide-react";
|
||||
import { Report } from "revolt-api";
|
||||
|
||||
export default async function Reports() {
|
||||
const reports = (await fetchOpenReports())
|
||||
.reverse()
|
||||
.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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/*<Input placeholder="Search for reports..." disabled />*/}
|
||||
{reports.length ? (
|
||||
keyOrder
|
||||
.filter((key) => byCategory[key].length)
|
||||
.map((key) => {
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl">{authorNames[key] ?? key}</h1>
|
||||
{byCategory[key].map((report) => (
|
||||
<CardLink
|
||||
key={report._id}
|
||||
href={`/panel/reports/${report._id}`}
|
||||
>
|
||||
<ReportCard report={report} />
|
||||
</CardLink>
|
||||
))}{" "}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<h2 className="mt-8 flex justify-center">
|
||||
<PizzaIcon className="text-gray-400" />
|
||||
</h2>
|
||||
<h3 className="text-xs text-center pb-2 text-gray-400">
|
||||
You‘ve caught up for now.
|
||||
</h3>
|
||||
</>
|
||||
)}
|
||||
{byCategory["AssignedToCase"].length && (
|
||||
<details>
|
||||
<summary>
|
||||
<h1 className="text-xl inline">Reports assigned to cases</h1>
|
||||
</summary>
|
||||
<div className="flex flex-col gap-2">
|
||||
{byCategory["AssignedToCase"].map((report) => (
|
||||
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
||||
<ReportCard report={report} />
|
||||
</CardLink>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input placeholder="Search for reports..." disabled />
|
||||
{reports.length
|
||||
? reports.map((report) => (
|
||||
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
|
||||
<ReportCard report={report} />
|
||||
</CardLink>
|
||||
))
|
||||
: (<>
|
||||
<h2 className="mt-8 flex justify-center">
|
||||
<PizzaIcon className="text-gray-400" />
|
||||
</h2>
|
||||
<h3 className="text-xs text-center pb-2 text-gray-400">
|
||||
You‘ve caught up for now.
|
||||
</h3>
|
||||
</>)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
"use client"
|
||||
|
||||
import { UserReviewCard } from "@/components/pages/stream/UserReviewCard";
|
||||
import { Card, CardDescription } from "@/components/ui/card";
|
||||
import { Account, fetchAccountById, fetchUserById } from "@/lib/db";
|
||||
import { Circle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { User } from "revolt-api";
|
||||
|
||||
export default function UserStream() {
|
||||
const [connectionState, setConnectionState] = useState<"Connecting"|"Connected"|"Disconnected">("Connecting");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
|
||||
const connectionColour = useMemo(() => {
|
||||
switch(connectionState) {
|
||||
case "Connected": return "#55aa7f";
|
||||
case "Connecting": return "#fb923c";
|
||||
case "Disconnected": return "#ef4444";
|
||||
}
|
||||
}, [connectionState]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserById("01H6BZB5F4B6GTKSJCV6TRGZ22").then(setUser);
|
||||
fetchAccountById("01H6BZB5F4B6GTKSJCV6TRGZ22").then(setAccount);
|
||||
|
||||
setTimeout(() => setConnectionState("Connected"), 1000);
|
||||
}, []);
|
||||
|
||||
return account ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Card className="flex flex-row justify-end pr-2 h-[40px]">
|
||||
<CardDescription className="flex flex-row gap-2 items-center">
|
||||
<span className="flex flex-row items-center gap-1">
|
||||
{connectionState}
|
||||
<Circle color={connectionColour} fill={connectionColour} />
|
||||
</span>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2">
|
||||
<UserReviewCard user={user ?? undefined} account={account} />
|
||||
<UserReviewCard user={undefined} account={account} />
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import { Report } from "revolt-api";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import dayjs from "dayjs";
|
||||
import { decodeTime } from "ulid";
|
||||
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { CaseDocument } from "@/lib/db";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function CaseCard({ entry: entry }: { entry: CaseDocument }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
className={`overflow-ellipsis whitespace-nowrap overflow-x-clip ${
|
||||
entry.status !== "Open" ? "text-gray-500" : ""
|
||||
}`}
|
||||
>
|
||||
{entry.title || "No reason specified"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{entry._id.toString().substring(20, 26)} ·{" "}
|
||||
{dayjs(decodeTime(entry._id)).fromNow()} · {entry.author}{" "}
|
||||
{entry.status !== "Open" && entry.closed_at && (
|
||||
<>· Closed {dayjs(entry.closed_at).fromNow()}</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,23 +1,13 @@
|
|||
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 { ReportDocument } from "@/lib/db";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
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);
|
||||
|
||||
export function ReportCard({ report }: { report: Report }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
@ -47,25 +37,6 @@ export function ReportCard({ report }: { report: ReportDocument }) {
|
|||
{dayjs(decodeTime(report._id)).fromNow()}{" "}
|
||||
{report.status !== "Created" && report.closed_at && (
|
||||
<>· Closed {dayjs(report.closed_at).fromNow()}</>
|
||||
)}{" "}
|
||||
{report.case_id && (
|
||||
<>
|
||||
·{" "}
|
||||
<Badge className="align-middle" variant="secondary">
|
||||
Assigned
|
||||
</Badge>
|
||||
</>
|
||||
)}{" "}
|
||||
{report.status === "Created" && decodeTime(report._id) < dueDate && (
|
||||
<>
|
||||
·{" "}
|
||||
<Badge className="align-middle" variant="relatively-destructive">
|
||||
Due{" "}
|
||||
{dayjs()
|
||||
.add(dayjs(decodeTime(report._id)).diff(dueDate))
|
||||
.fromNow()}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,33 +1,22 @@
|
|||
"use client";
|
||||
"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 { 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";
|
||||
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;
|
||||
export default function SafetyNotesCard({ objectId, type, title }: {
|
||||
objectId: string,
|
||||
type: SafetyNotes["_id"]["type"],
|
||||
title?: string
|
||||
}) {
|
||||
const session = useSession();
|
||||
const [draft, setDraft] = useState("");
|
||||
|
@ -51,20 +40,18 @@ export default function SafetyNotesCard({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}
|
||||
</CardTitle>
|
||||
<CardTitle>{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{editing ? (
|
||||
<Textarea
|
||||
rows={8}
|
||||
{
|
||||
editing
|
||||
? <Textarea
|
||||
placeholder={
|
||||
error
|
||||
? error
|
||||
: ready
|
||||
? "Enter notes here... (save on unfocus)"
|
||||
: "Fetching notes..."
|
||||
? "Enter notes here... (save on unfocus)"
|
||||
: "Fetching notes..."
|
||||
}
|
||||
className="!min-h-[80px] max-h-[50vh]"
|
||||
disabled={!ready || error != null}
|
||||
|
@ -73,7 +60,7 @@ export default function SafetyNotesCard({
|
|||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={async () => {
|
||||
if (draft === value?.text ?? "") return setEditing(false);
|
||||
|
||||
|
||||
try {
|
||||
await updateSafetyNote(objectId, type, draft);
|
||||
setValue({
|
||||
|
@ -96,37 +83,33 @@ export default function SafetyNotesCard({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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 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>
|
||||
<CardDescription>
|
||||
{
|
||||
value
|
||||
? <>Last edited {dayjs(value.edited_at).fromNow()} by {value.edited_by}</>
|
||||
: <>No object note set</>
|
||||
}
|
||||
</CardDescription>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,20 +7,13 @@ 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)`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="bg-no-repeat bg-right text-left"
|
||||
style={{
|
||||
backgroundImage: user.profile?.background
|
||||
? `${gradient}, url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
|
||||
: gradient,
|
||||
? `linear-gradient(to right, white, rgba(255,0,0,0)), url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
|
||||
: "",
|
||||
backgroundSize: "75%",
|
||||
}}
|
||||
>
|
||||
|
@ -37,8 +30,6 @@ export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: s
|
|||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{user.bot && <Badge className="align-middle">Bot</Badge>}{" "}
|
||||
{user.flags == 1 && <Badge className="align-middle bg-orange-400">Suspended</Badge>}{" "}
|
||||
{user.flags == 4 && <Badge className="align-middle bg-red-700">Banned</Badge>}{" "}
|
||||
<div className="flex gap-2">
|
||||
{user.username}#{user.discriminator} {user.display_name}
|
||||
{
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { useState } from "react";
|
||||
import { createCase } from "@/lib/db";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function CreateCase() {
|
||||
const [title, setTitle] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">Create Case</h4>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<Label htmlFor="description">Title</Label>
|
||||
<Input
|
||||
id="description"
|
||||
className="col-span-2 h-8"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!title) return;
|
||||
createCase(title).then((id) =>
|
||||
router.push(`/panel/cases/${id}`)
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
|
@ -11,7 +11,7 @@ import {
|
|||
Siren,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
BookCopy,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
|
||||
export function NavigationLinks() {
|
||||
|
@ -35,18 +35,18 @@ export function NavigationLinks() {
|
|||
>
|
||||
<Siren className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/cases"
|
||||
>
|
||||
<BookCopy className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/inspect"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/stream"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/shield"
|
||||
|
|
|
@ -16,7 +16,7 @@ export function NavigationToolbar({ children }: { children: string }) {
|
|||
<Button variant="outline" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* <Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Star
|
||||
|
@ -49,7 +49,7 @@ export function NavigationToolbar({ children }: { children: string }) {
|
|||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover> */}
|
||||
</Popover>
|
||||
<h2 className="text-2xl">{children}</h2>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -23,7 +23,7 @@ export function LoginButton() {
|
|||
</Button>
|
||||
<img
|
||||
src={`https://api.gifbox.me/file/posts/aYON6GqiqpwSpiZmAbJoOtw8tM2uYsEU.webp`}
|
||||
className="h-[320px]"
|
||||
height={320}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -43,7 +43,7 @@ export function LoginButton() {
|
|||
</Button>
|
||||
<img
|
||||
src={`https://api.gifbox.me/file/posts/w7iUJfiyKA_zGkHN7Rr625WpaTHYgm4v.webp`}
|
||||
className="h-[320px]"
|
||||
height={320}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -45,7 +45,7 @@ export function AccountActions({
|
|||
const [emailDraft, setEmailDraft] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div className="flex gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="flex-1">
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -18,24 +18,12 @@ export function RelevantObjects({
|
|||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<h2 className="text-md text-center pb-2">Bots & Relations</h2>
|
||||
<h2 className="text-md text-center pb-2">Bots & Friends</h2>
|
||||
<ListCompactor
|
||||
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),
|
||||
]}
|
||||
data={users}
|
||||
Component={({ item }) => (
|
||||
<Link href={`/panel/inspect/user/${item._id}`}>
|
||||
<UserCard
|
||||
user={item}
|
||||
subtitle={
|
||||
item.bot?.owner == userId
|
||||
? "Owned bot"
|
||||
: item.relations?.find((relation) => relation._id == userId)?.status || ""
|
||||
}
|
||||
/>
|
||||
<UserCard user={item} subtitle="" />
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
|
@ -43,21 +31,12 @@ export function RelevantObjects({
|
|||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<h2 className="text-md text-center pb-2">Servers</h2>
|
||||
<ListCompactor
|
||||
// same as above
|
||||
data={[
|
||||
...servers.filter((server) => userId == server.owner),
|
||||
...servers.filter((server) => userId != server.owner),
|
||||
]}
|
||||
data={servers}
|
||||
Component={({ item }) => (
|
||||
<Link href={`/panel/inspect/server/${item._id}`}>
|
||||
<ServerCard
|
||||
server={item}
|
||||
subtitle={
|
||||
[
|
||||
userId === item.owner ? "Server Owner" : null,
|
||||
item.discoverable ? "Discoverable" : null,
|
||||
].filter(i => i).join(" · ")
|
||||
}
|
||||
subtitle={userId === item.owner ? "Server Owner" : ""}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
import { useState } from "react";
|
||||
import { useToast } from "../../ui/use-toast";
|
||||
import {
|
||||
assignReportToCase,
|
||||
rejectReport,
|
||||
reopenReport,
|
||||
resolveReport,
|
||||
|
@ -33,10 +32,6 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "../../ui/alert-dialog";
|
||||
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> = {
|
||||
resolved: (ref) =>
|
||||
|
@ -48,7 +43,7 @@ const template: Record<string, (ref: string) => string> = {
|
|||
"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.`,
|
||||
clarify: (ref) =>
|
||||
`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.`,
|
||||
`Your report (${ref}) needs clarification, please provide additional information.`,
|
||||
acknowledged: (ref) =>
|
||||
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
|
||||
default: (ref) =>
|
||||
|
@ -59,12 +54,11 @@ export function ReportActions({
|
|||
report,
|
||||
reference,
|
||||
}: {
|
||||
report: ReportDocument;
|
||||
report: Report;
|
||||
reference: string;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [reportDraft, setDraft] = useState(report);
|
||||
const [availableCases, setAvailableCases] = useState<CaseDocument[]>([]);
|
||||
|
||||
function rejectHandler(reason: string) {
|
||||
return async () => {
|
||||
|
@ -89,7 +83,6 @@ export function ReportActions({
|
|||
<ReportCard report={reportDraft} />
|
||||
|
||||
<Textarea
|
||||
rows={8}
|
||||
placeholder="Enter notes here... (save on unfocus)"
|
||||
className="!min-h-0 !h-[76px]"
|
||||
defaultValue={report.notes}
|
||||
|
@ -113,74 +106,6 @@ 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">
|
||||
{reportDraft.status === "Created" ? (
|
||||
<>
|
||||
|
|
|
@ -35,7 +35,7 @@ export function ServerActions({ server }: { server: Server }) {
|
|||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div className="flex gap-2">
|
||||
{serverDraft.discoverable ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
|
|
|
@ -23,14 +23,11 @@ import { Input } from "../../ui/input";
|
|||
import {
|
||||
banUser,
|
||||
closeReportsByUser,
|
||||
resetBotToken,
|
||||
sendAlert,
|
||||
suspendUser,
|
||||
transferBot,
|
||||
unsuspendUser,
|
||||
updateBotDiscoverability,
|
||||
updateUserBadges,
|
||||
wipeUser,
|
||||
wipeUserProfile,
|
||||
} from "@/lib/actions";
|
||||
import { useRef, useState } from "react";
|
||||
|
@ -40,8 +37,6 @@ import { Card, CardHeader } from "../../ui/card";
|
|||
import { cn } from "@/lib/utils";
|
||||
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];
|
||||
|
||||
|
@ -58,8 +53,6 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
displayName: false,
|
||||
status: false,
|
||||
});
|
||||
const [transferTarget, setTransferTarget] = useState<User | null>(null);
|
||||
const [transferResetToken, setTransferResetToken] = useState(true);
|
||||
|
||||
const userInaccessible = userDraft.flags === 4 || userDraft.flags === 2;
|
||||
|
||||
|
@ -110,7 +103,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div className="flex gap-2">
|
||||
{bot ? (
|
||||
botDraft!.discoverable ? (
|
||||
<Button
|
||||
|
@ -265,51 +258,6 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="flex-1 bg-pink-600" disabled={userInaccessible}>
|
||||
Wipe Messages
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to wipe this user's messages?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
All messages sent by this user will be deleted immediately.
|
||||
<br className="text-base/8" />
|
||||
<span className="text-red-700">
|
||||
This action is irreversible and{" "}
|
||||
<b className="font-bold">will not publish any events</b>!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="hover:bg-red-700 transition-all"
|
||||
onClick={() =>
|
||||
wipeUser(user._id, 0, true)
|
||||
.then(() => {
|
||||
setUserDraft((user) => ({ ...user, flags: 4 }));
|
||||
toast({ title: "Wiped user's messages" });
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: "Failed to wipe user's messages!",
|
||||
description: String(err),
|
||||
variant: "destructive",
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Ban
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="flex-1 bg-yellow-600">Bees</Button>
|
||||
|
@ -349,7 +297,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
This will send a message from the Platform Moderation
|
||||
account.
|
||||
</span>
|
||||
<Textarea
|
||||
<Input
|
||||
placeholder="Enter a message..."
|
||||
name="message"
|
||||
onChange={(e) =>
|
||||
|
@ -468,101 +416,6 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
|
|||
</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'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>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost">Close Open Reports</Button>
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { Account } from "@/lib/db";
|
||||
import { DropdownMenu } from "@radix-ui/react-dropdown-menu";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { User } from "revolt-api";
|
||||
import { decodeTime } from "ulid";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function UserReviewCard({ user, account }: { user?: User, account: Account }) {
|
||||
const router = useRouter();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
return <Card className="flex">
|
||||
<CardHeader className="flex-1">
|
||||
{
|
||||
user
|
||||
? <CardTitle>{user.username}#{user.discriminator}</CardTitle>
|
||||
: <CardTitle className="text-gray-500">Pending onboarding</CardTitle>
|
||||
}
|
||||
<CardDescription>{account.email} · {dayjs(decodeTime(account._id)).fromNow()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center py-0 gap-2">
|
||||
<Button className="bg-orange-400 hover:bg-orange-300">Suspend</Button>
|
||||
<Button variant="destructive">Ban</Button>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="px-2"><MoreHorizontal /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
navigator.clipboard.writeText(account._id);
|
||||
toast({
|
||||
title: "Copied ID",
|
||||
description: account._id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Copy ID
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
navigator.clipboard.writeText(account.email);
|
||||
toast({
|
||||
title: "Copied email",
|
||||
description: account.email,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Copy email
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
router.push(`/panel/inspect/account/${account._id}`);
|
||||
}}
|
||||
>
|
||||
Inspect account
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
router.push(`/panel/inspect/user/${user?._id}`);
|
||||
}}
|
||||
disabled={!user}
|
||||
>
|
||||
Inspect user
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => alert("todo")}
|
||||
disabled={true}
|
||||
>
|
||||
Block email provider
|
||||
</Button>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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",
|
||||
|
@ -14,8 +14,6 @@ const badgeVariants = cva(
|
|||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"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",
|
||||
},
|
||||
},
|
||||
|
@ -23,7 +21,7 @@ const badgeVariants = cva(
|
|||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
|
@ -32,7 +30,7 @@ export interface BadgeProps
|
|||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge, badgeVariants }
|
||||
|
|
|
@ -3,8 +3,15 @@ import { SafetyNotes, insertAuditLog } from "./db";
|
|||
|
||||
type Permission =
|
||||
| `authifier${
|
||||
| ""
|
||||
| `/classification${
|
||||
| ""
|
||||
| `/classification${"" | "/fetch" | "/create" | "/update" | "/delete"}`}`
|
||||
| "/fetch"
|
||||
| "/create"
|
||||
| "/update"
|
||||
| "/delete"
|
||||
}`
|
||||
}`
|
||||
| "publish_message"
|
||||
| "chat_message"
|
||||
| `accounts${
|
||||
|
@ -17,37 +24,26 @@ type Permission =
|
|||
| `bots${
|
||||
| ""
|
||||
| `/fetch${"" | "/by-id" | "/by-user"}`
|
||||
| `/update${"" | "/discoverability" | "/owner" | "/reset-token"}`}`
|
||||
| `/update${"" | "/discoverability"}`}`
|
||||
| `channels${
|
||||
| ""
|
||||
| `/fetch${"" | "/by-id" | "/by-server" | "/dm" | "/invites"}`
|
||||
| `/create${"" | "/dm" | "/invites"}`
|
||||
| `/update${"" | "/invites"}`}`
|
||||
| `messages${"" | `/fetch${"" | "/by-id" | "/by-user"}`}`
|
||||
| `cases${
|
||||
| ""
|
||||
| "/create"
|
||||
| `/fetch${"" | "/by-id" | "/open"}`
|
||||
| `/update${"" | "/close" | "/reopen" | "/notes"}`}`
|
||||
| `reports${
|
||||
| ""
|
||||
| `/fetch${
|
||||
| ""
|
||||
| "/by-id"
|
||||
| "/open"
|
||||
| `/related${
|
||||
| ""
|
||||
| "/by-content"
|
||||
| "/by-user"
|
||||
| "/by-case"
|
||||
| "/against-user"}`
|
||||
| `/related${"" | "/by-content" | "/by-user" | "/against-user"}`
|
||||
| `/snapshots${"" | "/by-report" | "/by-user"}`}`
|
||||
| `/update${
|
||||
| ""
|
||||
| "/notes"
|
||||
| "/resolve"
|
||||
| "/reject"
|
||||
| "/case"
|
||||
| "/reopen"
|
||||
| `/bulk-close${"" | "/by-user"}`}`}`
|
||||
| `sessions${"" | `/fetch${"" | "/by-account-id"}`}`
|
||||
|
@ -66,8 +62,6 @@ type Permission =
|
|||
| `/fetch${
|
||||
| ""
|
||||
| "/by-id"
|
||||
| "/by-tag"
|
||||
| "/bulk-by-username"
|
||||
| "/memberships"
|
||||
| "/strikes"
|
||||
| "/notices"
|
||||
|
@ -107,8 +101,6 @@ const PermissionSets = {
|
|||
// View open reports
|
||||
"view-open-reports": [
|
||||
"users/fetch/by-id",
|
||||
"cases/fetch/open",
|
||||
"cases/fetch/by-id",
|
||||
"reports/fetch/open",
|
||||
"reports/fetch/by-id",
|
||||
"reports/fetch/related",
|
||||
|
@ -120,12 +112,7 @@ const PermissionSets = {
|
|||
"reports/update/notes",
|
||||
"reports/update/resolve",
|
||||
"reports/update/reject",
|
||||
"reports/update/case",
|
||||
"reports/update/reopen",
|
||||
"cases/create",
|
||||
"cases/update/notes",
|
||||
"cases/update/close",
|
||||
"cases/update/reopen",
|
||||
] as Permission[],
|
||||
|
||||
// Revolt Discover
|
||||
|
@ -152,10 +139,7 @@ const PermissionSets = {
|
|||
"users/update/badges",
|
||||
|
||||
"servers/update/owner",
|
||||
|
||||
"bots/fetch/by-user",
|
||||
"bots/update/reset-token",
|
||||
"bots/update/owner",
|
||||
"servers/update/add-member",
|
||||
|
||||
"accounts/fetch/by-id",
|
||||
"accounts/fetch/by-email",
|
||||
|
@ -176,18 +160,12 @@ const PermissionSets = {
|
|||
// Moderate users
|
||||
"moderate-users": [
|
||||
"users/fetch/by-id",
|
||||
"users/fetch/by-tag",
|
||||
"users/fetch/bulk-by-username",
|
||||
"users/fetch/strikes",
|
||||
"users/fetch/notices",
|
||||
|
||||
"bots/fetch/by-user",
|
||||
"bots/update/reset-token",
|
||||
"bots/update/owner",
|
||||
|
||||
// "messages/fetch/by-user",
|
||||
"users/fetch/memberships",
|
||||
"users/fetch/relations",
|
||||
// "users/fetch/memberships",
|
||||
"servers/fetch",
|
||||
|
||||
"messages/fetch/by-id",
|
||||
|
@ -197,8 +175,6 @@ const PermissionSets = {
|
|||
"channels/create/dm",
|
||||
|
||||
"servers/update/quarantine",
|
||||
"servers/update/owner",
|
||||
"servers/update/add-member",
|
||||
"backup/fetch",
|
||||
|
||||
"reports/fetch/related/by-user",
|
||||
|
@ -223,7 +199,9 @@ const PermissionSets = {
|
|||
"safety_notes/update",
|
||||
] as Permission[],
|
||||
|
||||
authifier: ["authifier/classification"] as Permission[],
|
||||
"authifier": [
|
||||
"authifier/classification",
|
||||
] as Permission[],
|
||||
};
|
||||
|
||||
const Roles = {
|
||||
|
@ -244,32 +222,22 @@ const ACL: Record<string, Set<Permission>> = {
|
|||
...Roles["revolt-discover"],
|
||||
...Roles["user-support"],
|
||||
] as Permission[]),
|
||||
"lea@revolt.chat": new Set([
|
||||
"lea@janderedev.xyz": new Set([
|
||||
...Roles["moderator"],
|
||||
...Roles["revolt-discover"],
|
||||
...Roles["user-support"],
|
||||
] as Permission[]),
|
||||
"tom@revolt.chat": new Set([
|
||||
"infi@infi.sh": new Set([
|
||||
...Roles["moderator"],
|
||||
...Roles["revolt-discover"],
|
||||
...Roles["user-support"],
|
||||
] as Permission[]),
|
||||
"jen@revolt.chat": new Set([
|
||||
"beartechtalks@gmail.com": new Set([
|
||||
...Roles["moderator"],
|
||||
...Roles["revolt-discover"],
|
||||
...Roles["user-support"],
|
||||
] as Permission[]),
|
||||
"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([
|
||||
"me@zomatree.live": new Set([
|
||||
...Roles["moderator"],
|
||||
...Roles["revolt-discover"],
|
||||
...Roles["user-support"],
|
||||
|
@ -306,5 +274,5 @@ export async function checkPermission(
|
|||
if (!(await hasPermissionFromSession(permission)))
|
||||
throw `Missing permission ${permission}`;
|
||||
|
||||
return await insertAuditLog(permission, context, args);
|
||||
await insertAuditLog(permission, context, args);
|
||||
}
|
||||
|
|
406
lib/actions.ts
406
lib/actions.ts
|
@ -4,10 +4,8 @@ import { readFile, readdir, writeFile } from "fs/promises";
|
|||
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
|
||||
import mongo, {
|
||||
Account,
|
||||
CaseDocument,
|
||||
ChannelInvite,
|
||||
EmailClassification,
|
||||
ReportDocument,
|
||||
createDM,
|
||||
fetchAccountById,
|
||||
findDM,
|
||||
|
@ -30,7 +28,6 @@ import {
|
|||
} from "revolt-api";
|
||||
import { checkPermission } from "./accessPermissions";
|
||||
import { Long } from "mongodb";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function sendAlert(userId: string, content: string) {
|
||||
await checkPermission("users/create/alert", userId, { content });
|
||||
|
@ -100,48 +97,6 @@ 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) {
|
||||
await checkPermission("reports/update/resolve", reportId);
|
||||
|
||||
|
@ -160,24 +115,6 @@ export async function resolveReport(reportId: string) {
|
|||
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) {
|
||||
await checkPermission("reports/update/reject", reportId, { reason });
|
||||
|
||||
|
@ -220,23 +157,6 @@ export async function reopenReport(reportId: string) {
|
|||
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) {
|
||||
await checkPermission("reports/update/bulk-close/by-user", userId);
|
||||
|
||||
|
@ -287,8 +207,8 @@ export async function deleteMFARecoveryCodes(userId: string) {
|
|||
$unset: {
|
||||
"mfa.recovery_codes": 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function disableMFA(userId: string) {
|
||||
|
@ -304,8 +224,8 @@ export async function disableMFA(userId: string) {
|
|||
$unset: {
|
||||
"mfa.totp_token": 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function changeAccountEmail(userId: string, email: string) {
|
||||
|
@ -366,7 +286,9 @@ export async function verifyAccountEmail(userId: string) {
|
|||
export async function lookupEmail(email: string): Promise<string | false> {
|
||||
await checkPermission("accounts/fetch/by-email", email);
|
||||
|
||||
const accounts = mongo().db("revolt").collection<Account>("accounts");
|
||||
const accounts = mongo()
|
||||
.db("revolt")
|
||||
.collection<Account>("accounts");
|
||||
|
||||
let result = await accounts.findOne({ email: email });
|
||||
if (result) return result._id;
|
||||
|
@ -446,21 +368,15 @@ export async function updateUserBadges(userId: string, badges: number) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function wipeUser(
|
||||
userId: string,
|
||||
flags = 4,
|
||||
onlyMessages = false
|
||||
) {
|
||||
export async function wipeUser(userId: string, flags = 4) {
|
||||
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
|
||||
|
||||
await checkPermission("users/action/wipe", userId, { flags });
|
||||
|
||||
const user = onlyMessages
|
||||
? null
|
||||
: await mongo()
|
||||
.db("revolt")
|
||||
.collection<User>("users")
|
||||
.findOne({ _id: userId });
|
||||
const user = await mongo()
|
||||
.db("revolt")
|
||||
.collection<User>("users")
|
||||
.findOne({ _id: userId });
|
||||
|
||||
const messages = await mongo()
|
||||
.db("revolt")
|
||||
|
@ -468,28 +384,24 @@ export async function wipeUser(
|
|||
.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 dms = 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();
|
||||
const memberships = await mongo()
|
||||
.db("revolt")
|
||||
.collection<{ _id: { user: string; server: string } }>("server_members")
|
||||
.find({ "_id.user": userId })
|
||||
.toArray();
|
||||
|
||||
// retrieve messages, dm channels, relationships, server memberships
|
||||
const backup = {
|
||||
_event: onlyMessages ? "messages" : "wipe",
|
||||
_event: "wipe",
|
||||
user,
|
||||
messages,
|
||||
dms,
|
||||
|
@ -509,14 +421,12 @@ export async function wipeUser(
|
|||
.filter((attachment) => attachment)
|
||||
.map((attachment) => attachment!._id);
|
||||
|
||||
if (!onlyMessages) {
|
||||
if (backup.user?.avatar) {
|
||||
attachmentIds.push(backup.user.avatar._id);
|
||||
}
|
||||
if (backup.user?.avatar) {
|
||||
attachmentIds.push(backup.user.avatar._id);
|
||||
}
|
||||
|
||||
if (backup.user?.profile?.background) {
|
||||
attachmentIds.push(backup.user.profile.background._id);
|
||||
}
|
||||
if (backup.user?.profile?.background) {
|
||||
attachmentIds.push(backup.user.profile.background._id);
|
||||
}
|
||||
|
||||
if (attachmentIds.length) {
|
||||
|
@ -539,46 +449,44 @@ export async function wipeUser(
|
|||
author: userId,
|
||||
});
|
||||
|
||||
if (!onlyMessages) {
|
||||
// delete server memberships
|
||||
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
|
||||
"_id.user": userId,
|
||||
});
|
||||
// delete server memberships
|
||||
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
|
||||
"_id.user": userId,
|
||||
});
|
||||
|
||||
// disable account
|
||||
await disableAccount(userId);
|
||||
// disable account
|
||||
await disableAccount(userId);
|
||||
|
||||
// clear user profile
|
||||
await mongo()
|
||||
.db("revolt")
|
||||
.collection<User>("users")
|
||||
.updateOne(
|
||||
{
|
||||
_id: userId,
|
||||
// clear user profile
|
||||
await mongo()
|
||||
.db("revolt")
|
||||
.collection<User>("users")
|
||||
.updateOne(
|
||||
{
|
||||
_id: userId,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
flags,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
flags,
|
||||
},
|
||||
$unset: {
|
||||
avatar: 1,
|
||||
profile: 1,
|
||||
status: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
$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,
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -689,7 +597,10 @@ export async function updateServerOwner(serverId: string, userId: string) {
|
|||
await mongo()
|
||||
.db("revolt")
|
||||
.collection<Server>("servers")
|
||||
.updateOne({ _id: serverId }, { $set: { owner: userId } });
|
||||
.updateOne(
|
||||
{ _id: serverId },
|
||||
{ $set: { owner: userId } },
|
||||
);
|
||||
|
||||
await publishMessage(serverId, {
|
||||
type: "ServerUpdate",
|
||||
|
@ -701,16 +612,8 @@ export async function updateServerOwner(serverId: string, userId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function addServerMember(
|
||||
serverId: string,
|
||||
userId: string,
|
||||
withEvent: boolean
|
||||
) {
|
||||
await checkPermission("servers/update/add-member", {
|
||||
serverId,
|
||||
userId,
|
||||
withEvent,
|
||||
});
|
||||
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")
|
||||
|
@ -739,7 +642,7 @@ export async function addServerMember(
|
|||
joined_at: Long.fromNumber(Date.now()) as unknown as string,
|
||||
});
|
||||
|
||||
await publishMessage(userId + "!", {
|
||||
await publishMessage(userId + '!', {
|
||||
type: "ServerCreate",
|
||||
id: serverId,
|
||||
channels: channels,
|
||||
|
@ -782,11 +685,11 @@ export async function quarantineServer(serverId: string, message: string) {
|
|||
server,
|
||||
members,
|
||||
invites,
|
||||
};
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
`./exports/${new Date().toISOString()} - ${serverId}.json`,
|
||||
JSON.stringify(backup)
|
||||
JSON.stringify(backup),
|
||||
);
|
||||
|
||||
await mongo()
|
||||
|
@ -799,7 +702,7 @@ export async function quarantineServer(serverId: string, message: string) {
|
|||
owner: "0".repeat(26),
|
||||
analytics: false,
|
||||
discoverable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -824,11 +727,10 @@ export async function quarantineServer(serverId: string, message: string) {
|
|||
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);
|
||||
|
||||
if (!dm) dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
|
||||
|
||||
await sendChatMessage({
|
||||
_id: messageId,
|
||||
author: PLATFORM_MOD_ID,
|
||||
|
@ -917,83 +819,6 @@ 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) {
|
||||
if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access";
|
||||
await checkPermission("accounts/restore", accountId);
|
||||
|
@ -1061,19 +886,15 @@ export async function fetchBackups() {
|
|||
await checkPermission("backup/fetch", null);
|
||||
|
||||
return await Promise.all(
|
||||
(
|
||||
await readdir("./exports", { withFileTypes: true })
|
||||
)
|
||||
(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) {}
|
||||
type = JSON.parse((await readFile(`./exports/${file.name}`)).toString("utf-8"))._event;
|
||||
} catch(e) {}
|
||||
|
||||
return { name: file.name, type: type };
|
||||
return { name: file.name, type: type }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -1084,9 +905,7 @@ export async function fetchBackup(name: string) {
|
|||
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
|
||||
}
|
||||
|
||||
export async function fetchEmailClassifications(): Promise<
|
||||
EmailClassification[]
|
||||
> {
|
||||
export async function fetchEmailClassifications(): Promise<EmailClassification[]> {
|
||||
await checkPermission("authifier/classification/fetch", null);
|
||||
|
||||
return await mongo()
|
||||
|
@ -1096,34 +915,27 @@ export async function fetchEmailClassifications(): Promise<
|
|||
.toArray();
|
||||
}
|
||||
|
||||
export async function createEmailClassification(
|
||||
domain: string,
|
||||
classification: string
|
||||
) {
|
||||
await checkPermission("authifier/classification/create", {
|
||||
domain,
|
||||
classification,
|
||||
});
|
||||
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 });
|
||||
.insertOne(
|
||||
{ _id: domain, classification },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateEmailClassification(
|
||||
domain: string,
|
||||
classification: string
|
||||
) {
|
||||
await checkPermission("authifier/classification/update", {
|
||||
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 } });
|
||||
.updateOne(
|
||||
{ _id: domain },
|
||||
{ $set: { classification } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEmailClassification(domain: string) {
|
||||
|
@ -1132,31 +944,7 @@ export async function deleteEmailClassification(domain: string) {
|
|||
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();
|
||||
.deleteOne(
|
||||
{ _id: domain },
|
||||
);
|
||||
}
|
||||
|
|
151
lib/db.ts
151
lib/db.ts
|
@ -21,19 +21,6 @@ import { getServerSession } from "next-auth";
|
|||
|
||||
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() {
|
||||
if (!client) {
|
||||
client = new MongoClient(process.env.MONGODB!);
|
||||
|
@ -68,8 +55,6 @@ export async function insertAuditLog(
|
|||
context,
|
||||
args,
|
||||
});
|
||||
|
||||
return session!.user!.email!;
|
||||
}
|
||||
|
||||
export async function fetchBotById(id: string) {
|
||||
|
@ -131,33 +116,34 @@ export type Account = {
|
|||
export async function fetchAccountById(id: string) {
|
||||
await checkPermission("accounts/fetch/by-id", id);
|
||||
|
||||
return (await mongo()
|
||||
return await mongo()
|
||||
.db("revolt")
|
||||
.collection<Account>("accounts")
|
||||
.aggregate([
|
||||
{
|
||||
$match: { _id: id },
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
password: 0,
|
||||
"mfa.totp_token.secret": 0,
|
||||
.aggregate(
|
||||
[
|
||||
{
|
||||
$match: { _id: id },
|
||||
},
|
||||
},
|
||||
{
|
||||
$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,
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
password: 0,
|
||||
"mfa.totp_token.secret": 0,
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
.next()) as WithId<Account>;
|
||||
{
|
||||
$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) {
|
||||
|
@ -302,7 +288,7 @@ export async function fetchServers(query: Filter<Server>) {
|
|||
}
|
||||
|
||||
// `vanity` should eventually be added to the backend as well
|
||||
export type ChannelInvite = Invite & { vanity?: boolean };
|
||||
export type ChannelInvite = Invite & { vanity?: boolean }
|
||||
|
||||
export async function fetchInvites(query: Filter<ChannelInvite>) {
|
||||
await checkPermission("channels/fetch/invites", query);
|
||||
|
@ -320,12 +306,12 @@ export async function fetchInvites(query: Filter<ChannelInvite>) {
|
|||
.toArray();
|
||||
|
||||
const users = await mongo()
|
||||
.db("revolt")
|
||||
.collection<User>("users")
|
||||
.find({ _id: { $in: invites.map((invite) => invite.creator) } })
|
||||
.toArray();
|
||||
.db("revolt")
|
||||
.collection<User>("users")
|
||||
.find({ _id: { $in: invites.map((invite) => invite.creator) } })
|
||||
.toArray();
|
||||
|
||||
return { invites, channels, users };
|
||||
return { invites, channels, users }
|
||||
}
|
||||
|
||||
export async function fetchMessageById(id: string) {
|
||||
|
@ -372,7 +358,7 @@ export async function fetchOpenReports() {
|
|||
|
||||
return await mongo()
|
||||
.db("revolt")
|
||||
.collection<ReportDocument>("safety_reports")
|
||||
.collection<Report>("safety_reports")
|
||||
.find(
|
||||
{ status: "Created" },
|
||||
{
|
||||
|
@ -384,23 +370,6 @@ export async function fetchOpenReports() {
|
|||
.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) {
|
||||
await checkPermission("reports/fetch/related/by-content", contentId);
|
||||
|
||||
|
@ -435,23 +404,6 @@ export async function fetchReportsByUser(userId: string) {
|
|||
.toArray();
|
||||
}
|
||||
|
||||
export async function fetchReportsByCase(caseId: string) {
|
||||
await checkPermission("reports/fetch/related/by-case", caseId);
|
||||
|
||||
return await mongo()
|
||||
.db("revolt")
|
||||
.collection<ReportDocument>("safety_reports")
|
||||
.find(
|
||||
{ case_id: caseId },
|
||||
{
|
||||
sort: {
|
||||
_id: -1,
|
||||
},
|
||||
}
|
||||
)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function fetchReportsAgainstUser(userId: string) {
|
||||
await checkPermission("reports/fetch/related/against-user", userId);
|
||||
|
||||
|
@ -511,27 +463,6 @@ export async function fetchReportById(id: string) {
|
|||
.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) {
|
||||
await checkPermission("users/fetch/memberships", userId);
|
||||
|
||||
|
@ -631,19 +562,13 @@ export async function fetchAuthifierEmailClassification(provider: string) {
|
|||
}
|
||||
|
||||
export type SafetyNotes = {
|
||||
_id: {
|
||||
id: string;
|
||||
type: "message" | "channel" | "server" | "user" | "account" | "global";
|
||||
};
|
||||
_id: { id: string, type: "message" | "channel" | "server" | "user" | "account" | "global" };
|
||||
text: string;
|
||||
edited_by: string;
|
||||
edited_at: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSafetyNote(
|
||||
objectId: string,
|
||||
type: SafetyNotes["_id"]["type"]
|
||||
) {
|
||||
export async function fetchSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"]) {
|
||||
await checkPermission(`safety_notes/fetch/${type}`, objectId);
|
||||
|
||||
return mongo()
|
||||
|
@ -652,11 +577,7 @@ export async function fetchSafetyNote(
|
|||
.findOne({ _id: { id: objectId, type: type } });
|
||||
}
|
||||
|
||||
export async function updateSafetyNote(
|
||||
objectId: string,
|
||||
type: SafetyNotes["_id"]["type"],
|
||||
note: string
|
||||
) {
|
||||
export async function updateSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"], note: string) {
|
||||
await checkPermission(`safety_notes/update/${type}`, objectId);
|
||||
|
||||
const session = await getServerSession();
|
||||
|
@ -673,6 +594,6 @@ export async function updateSafetyNote(
|
|||
edited_by: session?.user?.email ?? "",
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@
|
|||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.263.0",
|
||||
"mongodb": "^5.7.0",
|
||||
"nanoid": "^5.0.1",
|
||||
"next": "13.4.12",
|
||||
"next-auth": "^4.22.3",
|
||||
"ntfy": "^1.3.1",
|
||||
|
|
|
@ -80,9 +80,6 @@ dependencies:
|
|||
mongodb:
|
||||
specifier: ^5.7.0
|
||||
version: 5.7.0
|
||||
nanoid:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
next:
|
||||
specifier: 13.4.12
|
||||
version: 13.4.12(react-dom@18.2.0)(react@18.2.0)(sass@1.64.1)
|
||||
|
@ -3757,12 +3754,6 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
/nanoid@5.0.1:
|
||||
resolution: {integrity: sha512-vWeVtV5Cw68aML/QaZvqN/3QQXc6fBfIieAlu05m7FZW2Dgb+3f0xc0TTxuJW+7u30t7iSDTV/j3kVI0oJqIfQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
dev: false
|
||||
|
|
Loading…
Reference in New Issue