diff --git a/app/panel/inspect/account/[id]/page.tsx b/app/panel/inspect/account/[id]/page.tsx
index 29697a6..3c487c6 100644
--- a/app/panel/inspect/account/[id]/page.tsx
+++ b/app/panel/inspect/account/[id]/page.tsx
@@ -1,26 +1,33 @@
import { UserCard } from "@/components/cards/UserCard";
+import { EmailClassificationCard } from "@/components/cards/authifier/EmailClassificationCard";
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
-import { fetchUserById } from "@/lib/db";
+import { AccountActions } from "@/components/inspector/AccountActions";
+import { fetchAccountById, fetchUserById } from "@/lib/db";
import { notFound } from "next/navigation";
+import { User } from "revolt-api";
export default async function User({
params,
}: {
params: { id: string; type: string };
}) {
+ const account = await fetchAccountById(params.id);
+ if (!account) return notFound();
+
const user = await fetchUserById(params.id);
- // if (!user) return notFound();
return (
Inspecting Account
- {user &&
}
- update password, reset 2FA, disable / undisable (disabled if pending
+ {user &&
}
+
+
+ {/*TODO? update password, reset 2FA, disable / undisable (disabled if pending
delete), delete / cancel delete
list active 2FA methods without keys
- email account
+ email account*/}
);
}
diff --git a/components/cards/authifier/EmailClassificationCard.tsx b/components/cards/authifier/EmailClassificationCard.tsx
new file mode 100644
index 0000000..b324fae
--- /dev/null
+++ b/components/cards/authifier/EmailClassificationCard.tsx
@@ -0,0 +1,22 @@
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { fetchAuthifierEmailClassification } from "@/lib/db";
+
+export async function EmailClassificationCard({ email }: { email: string }) {
+ const provider = email.split("@").pop() ?? "";
+ const providerInfo = await fetchAuthifierEmailClassification(provider);
+ if (!providerInfo) return null;
+
+ return (
+
+
+ Email Classification
+ {providerInfo.classification}
+
+
+ );
+}
diff --git a/components/inspector/AccountActions.tsx b/components/inspector/AccountActions.tsx
new file mode 100644
index 0000000..00df92a
--- /dev/null
+++ b/components/inspector/AccountActions.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "../ui/button";
+import { useToast } from "../ui/use-toast";
+import type { Account } from "@/lib/db";
+import { User } from "revolt-api";
+import {
+ cancelAccountDeletion,
+ disableAccount,
+ queueAccountDeletion,
+ restoreAccount,
+} from "@/lib/actions";
+import dayjs from "dayjs";
+
+import relativeTime from "dayjs/plugin/relativeTime";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "../ui/alert-dialog";
+dayjs.extend(relativeTime);
+
+export function AccountActions({
+ account,
+ user,
+}: {
+ account: Account;
+ user?: User;
+}) {
+ const { toast } = useToast();
+
+ const [accountDraft, setAccountDraft] = useState(account);
+
+ return (
+
+
+
+
+
+
+
+
+ Are you sure you want to{" "}
+ {accountDraft.disabled ? "restore" : "disable"} this account?
+
+
+
+ Cancel
+ {
+ try {
+ if (accountDraft.disabled) {
+ await restoreAccount(account._id);
+ setAccountDraft((account) => ({
+ ...account!,
+ disabled: false,
+ }));
+ toast({
+ title: "Restored account",
+ });
+ } else {
+ await disableAccount(account._id);
+ setAccountDraft((account) => ({
+ ...account!,
+ disabled: true,
+ }));
+ toast({
+ title: "Disabled account",
+ });
+ }
+ } catch (err) {
+ toast({
+ title: "Failed to execute action",
+ description: String(err),
+ variant: "destructive",
+ });
+ }
+ }}
+ >
+ {accountDraft.disabled ? "Restore" : "Disable"}
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you sure you want to{" "}
+ {accountDraft.deletion?.status === "Scheduled"
+ ? "cancel deletion of"
+ : "queue deletion for"}{" "}
+ this account?
+
+
+
+ Cancel
+ {
+ try {
+ if (accountDraft.deletion?.status === "Scheduled") {
+ await cancelAccountDeletion(account._id);
+ setAccountDraft((account) => ({
+ ...account,
+ deletion: null,
+ }));
+ toast({
+ title: "Cancelled account deletion",
+ });
+ } else {
+ const $set = await queueAccountDeletion(account._id);
+ setAccountDraft((account) => ({ ...account!, ...$set }));
+ toast({
+ title: "Queued account for deletion",
+ });
+ }
+ } catch (err) {
+ toast({
+ title: "Failed to execute action",
+ description: String(err),
+ variant: "destructive",
+ });
+ }
+ }}
+ >
+ {accountDraft.deletion?.status === "Scheduled"
+ ? "Unqueue"
+ : "Queue"}
+
+
+
+
+
+ );
+}
diff --git a/lib/actions.ts b/lib/actions.ts
index e3af849..0de789d 100644
--- a/lib/actions.ts
+++ b/lib/actions.ts
@@ -3,6 +3,7 @@
import { writeFile } from "fs/promises";
import { PLATFORM_MOD_ID } from "./constants";
import mongo, {
+ Account,
createDM,
fetchChannels,
fetchMembershipsByUser,
@@ -179,6 +180,24 @@ export async function banUser(userId: string) {
return await wipeUser(userId, 4);
}
+export async function unsuspendUser(userId: string) {
+ await restoreAccount(userId);
+
+ await mongo()
+ .db("revolt")
+ .collection("users")
+ .updateOne(
+ {
+ _id: userId,
+ },
+ {
+ $unset: {
+ flags: 1,
+ },
+ }
+ );
+}
+
export async function updateServerFlags(serverId: string, flags: number) {
await mongo().db("revolt").collection("servers").updateOne(
{
@@ -237,3 +256,60 @@ export async function updateBotDiscoverability(botId: string, state: boolean) {
}
);
}
+
+export async function restoreAccount(accountId: string) {
+ await mongo()
+ .db("revolt")
+ .collection("accounts")
+ .updateOne(
+ {
+ _id: accountId,
+ },
+ {
+ $unset: {
+ disabled: 1,
+ },
+ }
+ );
+}
+
+export async function queueAccountDeletion(accountId: string) {
+ const twoWeeksFuture = new Date();
+ twoWeeksFuture.setDate(twoWeeksFuture.getDate() + 14);
+
+ const $set: Partial = {
+ disabled: true,
+ deletion: {
+ status: "Scheduled",
+ after: twoWeeksFuture.toISOString(),
+ },
+ };
+
+ await disableAccount(accountId);
+ await mongo().db("revolt").collection("accounts").updateOne(
+ {
+ _id: accountId,
+ },
+ {
+ $set,
+ }
+ );
+
+ return $set;
+}
+
+export async function cancelAccountDeletion(accountId: string) {
+ await mongo()
+ .db("revolt")
+ .collection("accounts")
+ .updateOne(
+ {
+ _id: accountId,
+ },
+ {
+ $unset: {
+ deletion: 1,
+ },
+ }
+ );
+}
diff --git a/lib/db.ts b/lib/db.ts
index 0f2cc24..6d502fe 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -33,6 +33,62 @@ export async function fetchBotById(id: string) {
.findOne({ _id: id });
}
+export type Account = {
+ _id: string;
+ email: string;
+ email_normalised: string;
+ verification:
+ | {
+ status: "Verified";
+ }
+ | {
+ status: "Pending";
+ token: string;
+ expiry: string;
+ }
+ | {
+ status: "Moving";
+ new_email: string;
+ token: string;
+ expiry: string;
+ };
+ password_reset: null | {
+ token: string;
+ expiry: string;
+ };
+ deletion:
+ | null
+ | {
+ status: "WaitingForVerification";
+ token: string;
+ expiry: string;
+ }
+ | {
+ status: "Scheduled";
+ after: string;
+ };
+ disabled: boolean;
+ lockout: null | {
+ attempts: number;
+ expiry: string;
+ };
+};
+
+export async function fetchAccountById(id: string) {
+ return await mongo()
+ .db("revolt")
+ .collection("accounts")
+ .findOne(
+ { _id: id },
+ {
+ projection: {
+ password: 0,
+ mfa: 0,
+ },
+ }
+ );
+}
+
export async function fetchUserById(id: string) {
return await mongo()
.db("revolt")
@@ -210,3 +266,15 @@ export async function fetchBotsByUser(userId: string) {
.find({ owner: userId })
.toArray();
}
+
+export type EmailClassification = {
+ _id: string;
+ classification: string;
+};
+
+export async function fetchAuthifierEmailClassification(provider: string) {
+ return await mongo()
+ .db("authifier")
+ .collection("email_classification")
+ .findOne({ _id: provider });
+}