Add account restrictions, appeals, and admin moderation tools
This commit is contained in:
205
lib/account-controls-api.ts
Normal file
205
lib/account-controls-api.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user