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