Add account restrictions, appeals, and admin moderation tools

This commit is contained in:
2026-10-13 12:32:37 +05:00
parent 018be5049e
commit cfe5820802
28 changed files with 1936 additions and 125 deletions

205
lib/account-controls-api.ts Normal file
View File

@@ -0,0 +1,205 @@
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required, user, rawUser } from "./supabase";
import { browserAccount } from "./request-security";
import { ServiceError, checkServiceError } from "./service-error";
import { canReviewMaps, isAdministrator } from "@/shared/permissions";
import {
accountActionInput,
accountAppealInput,
accountAppealReviewInput,
} from "@/shared/account-controls";
import { discordAfterResponse } from "./discord-announcements";
type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
const columns =
"id,display_name,role,account_status,account_reason,account_changed_at,account_status_version" as const;
const respond = (data: unknown, status = 200) =>
NextResponse.json(data, {
status,
headers: { "Cache-Control": "private, no-store" },
});
function accountError(error: { code?: string; message: string } | null) {
if (error && ["42501", "22023", "P0002"].includes(error.code ?? ""))
throw new ServiceError(
error.message,
error.code === "42501" ? 403 : error.code === "P0002" ? 404 : 400,
);
checkServiceError(error);
}
export async function accountControlsApi(
request: NextRequest,
path: string[],
{ jsonBody, limit }: Context,
) {
const db = admin();
if (path[0] === "ban" && path[1] === "appeal" && path.length === 2) {
// A restricted identity may access only its own status and appeal history.
const account = await browserAccount(request, rawUser);
const profile = required(
await db.from("profiles").select(columns).eq("id", account.id).single(),
);
if (request.method === "GET") {
const appeals = required(
await db
.from("account_appeals")
.select(
"id,owner_id,status_version,reason,state,created_at,review_reason,reviewed_at",
)
.eq("owner_id", account.id)
.order("created_at", { ascending: false })
.limit(50),
);
return respond({
account: { ...profile, status_version: profile.account_status_version },
appeals: appeals.map((a) => ({
...a,
display_name: profile.display_name,
})),
});
}
if (request.method === "POST") {
const body = accountAppealInput.parse(await jsonBody(request));
await limit("account-appeal:" + account.id, 5, 3600);
const submission = await db.rpc("submit_account_appeal", {
p_owner: account.id,
p_reason: body.reason,
});
accountError(submission.error);
const id = required(submission);
return respond({ id }, 201);
}
return respond({ error: "Method not allowed" }, 405);
}
const reviewer = await browserAccount(request, user);
const staff = required(
await db
.from("profiles")
.select("role,account_status")
.eq("id", reviewer.id)
.single(),
);
if (!canReviewMaps(staff.role) || staff.account_status !== "active")
throw new ServiceError("Moderator permission required", 403);
if (path[1] === "accounts") {
if (!isAdministrator(staff.role))
throw new ServiceError("Admin permission required", 403);
if (path.length === 2 && request.method === "GET") {
const search = z
.string()
.trim()
.max(80)
.parse(request.nextUrl.searchParams.get("q") ?? "");
let query = db
.from("profiles")
.select(columns)
.order("display_name")
.order("id")
.limit(30);
if (search)
query = z.string().uuid().safeParse(search).success
? query.eq("id", search)
: query.ilike(
"display_name",
"%" + search.replace(/[\\%_]/g, "\\$&") + "%",
);
return respond({
users: required(await query).map((p) => ({
...p,
status_version: p.account_status_version,
})),
viewerId: reviewer.id,
});
}
if (path.length === 3 && request.method === "POST") {
const target = z.string().uuid().parse(path[2]);
const body = accountActionInput.parse(await jsonBody(request));
if (body.action === "delete" && body.confirmation !== target)
throw new ServiceError(
"Type the account ID to confirm permanent deletion",
400,
);
await limit("account-actions:" + reviewer.id, 30, 3600);
const action = await db.rpc("admin_account_action", {
p_reviewer: reviewer.id,
p_user: target,
p_action: body.action === "delete" ? "prepare_delete" : body.action,
p_reason: body.reason,
});
accountError(action.error);
const result = required(action);
// Restriction and credential invalidation commit before Auth deletion. A
// failed delete leaves a blocked account that an Admin can retry safely.
if (body.action === "delete") {
const { error } = await db.auth.admin.deleteUser(target, false);
if (error)
throw new ServiceError(
"Account is blocked, but deletion could not finish. Retry permanent deletion.",
503,
);
const remaining = await db
.from("profiles")
.select("id")
.eq("id", target)
.maybeSingle();
if (remaining.error || remaining.data)
throw new ServiceError(
"Deletion could not be confirmed. Refresh the account list before retrying.",
503,
);
}
discordAfterResponse();
return respond({ ok: true, result });
}
}
if (path[1] === "account-appeals") {
if (path.length === 2 && request.method === "GET") {
const appeals = required(
await db
.from("account_appeals")
.select(
"id,owner_id,status_version,reason,state,created_at,review_reason,reviewed_at",
)
.eq("state", "open")
.order("created_at")
.limit(100),
);
const profiles = appeals.length
? required(
await db
.from("profiles")
.select("id,display_name,account_reason")
.in("id", [...new Set(appeals.map((a) => a.owner_id))]),
)
: [];
const names = new Map(profiles.map((p) => [p.id, p.display_name]));
const reasons = new Map(profiles.map((p) => [p.id, p.account_reason]));
return respond({
appeals: appeals.map((a) => ({
...a,
display_name: names.get(a.owner_id) ?? "Deleted player",
ban_reason: reasons.get(a.owner_id) ?? null,
})),
});
}
if (path.length === 3 && request.method === "POST") {
const id = z.string().uuid().parse(path[2]);
const body = accountAppealReviewInput.parse(await jsonBody(request));
await limit("account-appeal-review:" + reviewer.id, 60, 3600);
const review = await db.rpc("review_account_appeal", {
p_reviewer: reviewer.id,
p_appeal: id,
p_decision: body.decision,
p_reason: body.reason,
});
accountError(review.error);
return respond({ ok: true });
}
}
return respond({ error: "Not found" }, 404);
}