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

30
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
import { getI18n } from "@/lib/i18n";
import { staffPage } from "@/lib/staff-page";
export const metadata = { title: "Admin Tools" };
export default async function AdminPage() {
const [{ t }] = await Promise.all([getI18n(), staffPage(true)]);
return (
<>
<Header />
<main className="section page-content moderation-page">
<span className="eyebrow">MINEGEO ADMIN</span>
<h1>{t("Admin Tools.")}</h1>
<p className="lead">
{t(
"Manage accounts, site settings, integrations and community reports.",
)}
</p>
<p>
<Link className="button" href="/moderation">
{t("Open Moderation Tools")}
</Link>
</p>
<Moderation mode="admin" />
</main>
<Footer />
</>
);
}

View File

@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { PasswordForm } from "@/components/PasswordForm";
import { authClient, user } from "@/lib/supabase";
import { authClient, rawUser } from "@/lib/supabase";
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
export async function generateMetadata() {
const { t } = await getI18n();
@@ -12,7 +12,7 @@ export async function generateMetadata() {
export default async function ResetPassword() {
const { t } = await getI18n();
const [account, jar, client] = await Promise.all([
user(),
rawUser(),
cookies(),
authClient(),
]);

View File

@@ -0,0 +1,67 @@
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.heading h2,
.heading h3 {
margin: 0;
}
.heading {
margin-bottom: 18px;
}
.search {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 16px;
margin: 24px 0;
}
.search label {
flex: 1 1 240px;
min-width: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.list {
display: grid;
gap: 24px;
margin-top: 24px;
min-width: 0;
}
.record {
border: 1px solid var(--line);
border-radius: 12px;
padding: clamp(16px, 3vw, 24px);
min-width: 0;
}
.record code,
.record h3,
.record p {
overflow-wrap: anywhere;
}
.record h3 {
font-size: 22px;
}
.reason {
white-space: pre-wrap;
}
.confirm {
border-top: 1px solid var(--line);
padding-top: 24px;
margin-top: 24px;
}
.confirm label {
margin-top: 20px;
}
.page {
max-width: 900px;
}
.page p {
line-height: 1.8;
}

44
app/ban/appeal/page.tsx Normal file
View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { AccountAppeal } from "@/components/AccountAppeal";
import { rawUser } from "@/lib/supabase";
import { getI18n } from "@/lib/i18n";
import styles from "./page.module.css";
export const metadata = { title: "Account access & appeals" };
export const dynamic = "force-dynamic";
export default async function AppealPage() {
const [account, { locale }] = await Promise.all([rawUser(), getI18n()]);
const ru = locale === "ru";
return (
<>
<Header />
<main className={`section page-content ${styles.page}`}>
<span className="eyebrow">MINEGEO</span>
<h1>{ru ? "Доступ и апелляции." : "Account access & appeals."}</h1>
{account ? (
<AccountAppeal />
) : (
<>
<p className="lead">
{ru
? "Войдите в заблокированный аккаунт, чтобы прочитать причину и подать апелляцию."
: "Log in to the affected account to see the reason and submit a ban appeal."}
</p>
<Link
className="button primary"
href="/log-in?next=%2Fban%2Fappeal"
>
{ru ? "Войти" : "Log in"}
</Link>
<p>
<Link href="/docs?page=account-bans">
{ru ? "Как работают апелляции" : "How appeals work"}
</Link>
</p>
</>
)}
</main>
<Footer />
</>
);
}

View File

@@ -21,7 +21,7 @@ import "./community.css";
import { themeInitScript } from "@/shared/theme";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase";
import { rawUser, accountState } from "@/lib/supabase";
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n();
@@ -41,7 +41,10 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
const [{ locale, preference, detectedLocale }, settings, account] =
await Promise.all([getI18n(), initialSiteSettings(), user()]);
await Promise.all([getI18n(), initialSiteSettings(), rawUser()]);
const active = account
? (await accountState(account.id)) === "active"
: false;
return (
<html lang={locale} data-theme="dark" suppressHydrationWarning>
<head>
@@ -56,7 +59,7 @@ export default async function RootLayout({
<SiteSettingsProvider initialSettings={settings}>
{children}
<SiteCursor />
<LegacyPasswordNotice enabled={!!account?.email} />
<LegacyPasswordNotice enabled={active && !!account?.email} />
</SiteSettingsProvider>
</I18nProvider>
<Analytics />

View File

@@ -2,7 +2,7 @@ import { getI18n } from "@/lib/i18n";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { SignIn } from "@/components/SignIn";
import { user } from "@/lib/supabase";
import { rawUser, accountState } from "@/lib/supabase";
import { authDestination } from "@/shared/account";
export async function generateMetadata() {
const { t } = await getI18n();
@@ -15,8 +15,10 @@ export default async function LogIn({
}) {
const query = await searchParams;
const next = authDestination(query.next);
if (await user())
redirect(next === "/auth/reset-password" ? "/settings" : next);
const account = await rawUser();
if (account && (await accountState(account.id)) !== "active")
redirect("/ban/appeal");
if (account) redirect(next === "/auth/reset-password" ? "/settings" : next);
return (
<>
<Header />

View File

@@ -1,19 +1,28 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
import Link from "next/link";
import { staffPage } from "@/lib/staff-page";
import { isAdministrator } from "@/shared/permissions";
export const metadata = { title: "Moderation Tools" };
export default async function Review() {
const { t } = await getI18n();
const [{ t }, staff] = await Promise.all([getI18n(), staffPage()]);
return (
<>
<Header />
<main className="section page-content moderation-page">
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
<h1>{t("Map moderation.")}</h1>
<h1>{t("Moderation Tools.")}</h1>
<p className="lead">
{t(
"Review map quality and coverage before a world enters the public catalog.",
)}
{t("Review pending maps, map updates and account ban appeals.")}
</p>
{isAdministrator(staff.role) && (
<p>
<Link className="button" href="/admin">
{t("Open Admin Tools")}
</Link>
</p>
)}
<Moderation />
</main>
<Footer />

View File

@@ -0,0 +1,243 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type {
ManagedAccount,
AccountAppealRecord,
} from "@/shared/account-controls";
import styles from "@/app/ban/appeal/page.module.css";
export function AccountAppeal() {
const { locale } = useI18n();
const l = (en: string, ru: string) => (locale === "ru" ? ru : en);
const [data, setData] = useState<{
account: ManagedAccount;
appeals: AccountAppealRecord[];
} | null>(null);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(true);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const lock = useRef(false);
useEffect(() => {
let active = true;
api<{ account: ManagedAccount; appeals: AccountAppealRecord[] }>(
"ban/appeal",
)
.then((result) => {
if (active) setData(result);
})
.catch((e) => {
if (active) setError((e as Error).message);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
async function run(operation: () => Promise<void>) {
if (lock.current) return;
lock.current = true;
setBusy(true);
setError("");
try {
await operation();
} catch (e) {
setError((e as Error).message);
} finally {
lock.current = false;
setBusy(false);
}
}
async function reload() {
setData(await api("ban/appeal"));
setLoading(false);
}
const current = data?.appeals.find(
(a) => a.status_version === data.account.status_version,
);
return (
<section
aria-label={l(
"Account access and appeals",
"Доступ к аккаунту и апелляции",
)}
>
{error && (
<p className="error-message" role="alert">
{error}
</p>
)}
{message && (
<p className="muted" role="status">
{message}
</p>
)}
{loading && !error && (
<p role="status">
{l("Loading account status…", "Загрузка состояния аккаунта…")}
</p>
)}
<div className={styles.actions}>
<button
className="button"
disabled={busy}
onClick={() => void run(reload)}
>
{l("Refresh status", "Обновить статус")}
</button>
<button
className="button"
disabled={busy}
onClick={() =>
void run(async () => {
await api("auth/logout", {});
window.location.assign("/log-in");
})
}
>
{l("Log out", "Выйти")}
</button>
<Link className="button" href="/docs?page=account-bans">
{l("Read the guide", "Открыть руководство")}
</Link>
</div>
{data && (
<>
<div className={styles.record} style={{ marginTop: 32 }}>
<h2>
{data.account.account_status === "active"
? l("Your account is active", "Ваш аккаунт активен")
: data.account.account_status === "banned"
? l("Your account is banned", "Ваш аккаунт заблокирован")
: data.account.account_status === "deleting"
? l(
"Account deletion is pending",
"Ожидается удаление аккаунта",
)
: l(
"Your account is deactivated",
"Ваш аккаунт деактивирован",
)}
</h2>
{data.account.account_reason && (
<>
<strong>{l("Reason", "Причина")}</strong>
<p className={styles.reason}>{data.account.account_reason}</p>
</>
)}
{data.account.account_status === "active" ? (
<Link className="button primary" href="/servers">
{l("Continue playing", "Продолжить играть")}
</Link>
) : (
<p>
{l(
"Normal account access, uploads and API keys are blocked. You can still read the docs, view this page and sign out.",
"Обычный доступ к аккаунту, загрузки и ключи API заблокированы. Вы можете читать документацию, открывать эту страницу и выходить из аккаунта.",
)}
</p>
)}
{data.account.account_status === "deactivated" && (
<p>
{l(
"An Admin must restore a deactivated account. Ban appeals are available only for banned accounts. Contact the team through the community links below.",
"Деактивированный аккаунт может восстановить только администратор. Апелляции доступны только при блокировке. Свяжитесь с командой через ссылки сообщества внизу.",
)}
</p>
)}
{data.account.account_status === "deleting" && (
<p>
{l(
"An Admin must finish this action. A deleted account cannot be appealed or recovered here.",
"Администратор должен завершить это действие. Удалённый аккаунт нельзя восстановить или обжаловать здесь.",
)}
</p>
)}
</div>
{data.account.account_status === "banned" && !current && (
<form
className={styles.confirm}
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api("ban/appeal", { reason });
setReason("");
setMessage(
l(
"Appeal sent. A Moderator or Admin will review it.",
"Апелляция отправлена. Её рассмотрит модератор или администратор.",
),
);
await reload();
});
}}
>
<h2>{l("Appeal this ban", "Обжаловать блокировку")}</h2>
<p>
{l(
"Explain what happened and why the ban should be reconsidered. You can submit one appeal for each ban. Staff will leave their decision here.",
"Расскажите, что произошло и почему блокировку стоит пересмотреть. Для каждой блокировки можно подать одну апелляцию. Решение команды появится здесь.",
)}
</p>
<label className="field">
{l(
"Your explanation (10–2000 characters)",
"Ваше объяснение (10–2000 символов)",
)}
<textarea
required
minLength={10}
maxLength={2000}
rows={6}
disabled={busy}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<button
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy
? l("Sending…", "Отправка…")
: l("Send appeal", "Отправить апелляцию")}
</button>
</form>
)}
{data.appeals.length > 0 && (
<div className={styles.list}>
<h2>{l("Your appeals", "Ваши апелляции")}</h2>
{data.appeals.map((appeal) => (
<article className={styles.record} key={appeal.id}>
<span className="badge">
{appeal.state === "open"
? l("Awaiting review", "Ожидает рассмотрения")
: appeal.state === "approved"
? l("Approved", "Одобрена")
: l("Rejected", "Отклонена")}
</span>
<p className="muted">
{new Date(appeal.created_at).toLocaleString(locale)}
</p>
<p className={styles.reason}>{appeal.reason}</p>
{appeal.review_reason && (
<>
<strong>{l("Staff decision", "Решение команды")}</strong>
<p className={styles.reason}>{appeal.review_reason}</p>
</>
)}
</article>
))}
</div>
)}
</>
)}
</section>
);
}

View File

@@ -0,0 +1,418 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ShieldCheck, Users, RefreshCw } from "lucide-react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type {
ManagedAccount,
AccountAppealRecord,
} from "@/shared/account-controls";
import styles from "@/app/ban/appeal/page.module.css";
export function AccountControls({
administrator,
showAppeals = true,
}: {
administrator: boolean;
showAppeals?: boolean;
}) {
const { locale } = useI18n();
const l = (en: string, ru: string) => (locale === "ru" ? ru : en);
const [query, setQuery] = useState("");
const [users, setUsers] = useState<ManagedAccount[]>([]);
const [viewer, setViewer] = useState("");
const [searched, setSearched] = useState(false);
const [appeals, setAppeals] = useState<AccountAppealRecord[]>([]);
const [loading, setLoading] = useState(showAppeals);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const lock = useRef(false);
const [selected, setSelected] = useState<{
account: ManagedAccount;
action: "deactivate" | "ban" | "restore" | "delete";
} | null>(null);
const [reason, setReason] = useState("");
const [confirmation, setConfirmation] = useState("");
const [reviewReasons, setReviewReasons] = useState<Record<string, string>>(
{},
);
useEffect(() => {
if (!showAppeals) return;
let active = true;
api<{ appeals: AccountAppealRecord[] }>("moderation/account-appeals")
.then((data) => {
if (active) setAppeals(data.appeals);
})
.catch((e) => {
if (active) setError((e as Error).message);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [showAppeals]);
async function run(operation: () => Promise<void>) {
if (lock.current) return;
lock.current = true;
setBusy(true);
setError("");
setMessage("");
try {
await operation();
} catch (e) {
setError((e as Error).message);
} finally {
lock.current = false;
setBusy(false);
}
}
async function find() {
const result = await api<{ users: ManagedAccount[]; viewerId: string }>(
"moderation/accounts?q=" + encodeURIComponent(query),
);
setUsers(result.users);
setViewer(result.viewerId);
setSearched(true);
}
async function refreshAppeals() {
if (!showAppeals) return;
setLoading(true);
try {
setAppeals(
(
await api<{ appeals: AccountAppealRecord[] }>(
"moderation/account-appeals",
)
).appeals,
);
} finally {
setLoading(false);
}
}
async function save(event: React.FormEvent) {
event.preventDefault();
if (!selected) return;
const chosen = selected;
await run(async () => {
await api("moderation/accounts/" + chosen.account.id, {
action: chosen.action,
reason,
...(chosen.action === "delete" ? { confirmation } : {}),
});
setSelected(null);
setReason("");
setConfirmation("");
await find();
await refreshAppeals();
setMessage(
l("Account action completed.", "Действие с аккаунтом выполнено."),
);
});
}
const stateLabel = (status: string) =>
({
active: l("Active", "Активен"),
deactivated: l("Deactivated", "Деактивирован"),
banned: l("Banned", "Заблокирован"),
deleting: l("Deletion pending", "Ожидает удаления"),
})[status] ?? status;
const actionLabel = (action: string) =>
({
deactivate: l("Deactivate", "Деактивировать"),
ban: l("Ban", "Заблокировать"),
restore: l("Restore access", "Восстановить доступ"),
delete: l("Delete permanently", "Удалить навсегда"),
})[action] ?? action;
return (
<>
{error && (
<p className="error-message" role="alert">
{error}
</p>
)}
{message && (
<p className="muted" role="status">
{message}
</p>
)}
{administrator && (
<section className="tool-card" aria-labelledby="account-controls-title">
<div className={styles.heading}>
<Users size={24} aria-hidden="true" />
<h2 id="account-controls-title">
{l("Account management", "Управление аккаунтами")}
</h2>
</div>
<p>
{l(
"Deactivate access temporarily, ban with a reason, restore access, or permanently delete an account. Admin accounts are protected.",
"Временно отключайте доступ, блокируйте с указанием причины, восстанавливайте доступ или удаляйте аккаунты навсегда. Аккаунты администраторов защищены.",
)}
</p>
<form
className={styles.search}
onSubmit={(e) => {
e.preventDefault();
void run(find);
}}
>
<label className="field">
{l("Player name or account ID", "Имя игрока или ID аккаунта")}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
maxLength={80}
disabled={busy}
/>
</label>
<button className="button" disabled={busy}>
{l("Find accounts", "Найти аккаунты")}
</button>
</form>
{searched && users.length === 0 && (
<p>{l("No accounts found.", "Аккаунты не найдены.")}</p>
)}
<div className={styles.list}>
{users.map((account) => (
<article className={styles.record} key={account.id}>
<div className={styles.heading}>
<h3>{account.display_name}</h3>
<span className="badge">
{stateLabel(account.account_status)}
</span>
</div>
<p>
<code>{account.id}</code> · {account.role}
</p>
{account.account_reason && <p>{account.account_reason}</p>}
{account.role === "admin" || account.id === viewer ? (
<p className="muted">
{l("This account is protected.", "Этот аккаунт защищён.")}
</p>
) : (
<div className={styles.actions}>
{(["deactivate", "ban", "restore", "delete"] as const).map(
(action) => (
<button
className="button"
type="button"
key={action}
disabled={
busy ||
(action === "restore" &&
["active", "deleting"].includes(
account.account_status,
)) ||
(action === "deactivate" &&
account.account_status !== "active") ||
(action === "ban" &&
["banned", "deleting"].includes(
account.account_status,
))
}
onClick={() => {
setSelected({ account, action });
setReason("");
setConfirmation("");
setMessage("");
}}
>
{actionLabel(action)}
</button>
),
)}
</div>
)}
{selected?.account.id === account.id && (
<form
className={styles.confirm}
onSubmit={(e) => void save(e)}
>
<h3>
{actionLabel(selected.action)}: {account.display_name}
</h3>
<p>
{selected.action === "delete"
? l(
"Permanent deletion removes sign-in, profile, credentials and account appeals. Shared maps and community history remain without an account owner. This cannot be undone.",
"Удаление навсегда убирает вход, профиль, ключи и апелляции аккаунта. Общие карты и история сообщества сохраняются без владельца аккаунта. Отменить удаление нельзя.",
)
: l(
"The player will see your reason. Restricting access revokes existing keys and ends existing games and multiplayer participation.",
"Игрок увидит указанную причину. Ограничение доступа отзывает ключи и завершает текущие игры и участие в мультиплеере.",
)}
</p>
<label className="field">
{l(
"Reason (10–2000 characters)",
"Причина (10–2000 символов)",
)}
<textarea
required
minLength={10}
maxLength={2000}
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={busy}
/>
</label>
{selected.action === "delete" && (
<label className="field">
{l(
"Type the account ID to confirm",
"Введите ID аккаунта для подтверждения",
)}
<input
required
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
autoComplete="off"
disabled={busy}
/>
</label>
)}
<div className={styles.actions}>
<button
className="button primary"
disabled={
busy ||
reason.trim().length < 10 ||
(selected.action === "delete" &&
confirmation !== account.id)
}
>
{busy
? l("Working…", "Подождите…")
: l("Confirm action", "Подтвердить действие")}
</button>
<button
className="button"
type="button"
disabled={busy}
onClick={() => setSelected(null)}
>
{l("Cancel", "Отмена")}
</button>
</div>
</form>
)}
</article>
))}
</div>
</section>
)}
{showAppeals && (
<section className="tool-card" aria-labelledby="account-appeals-title">
<div className={styles.heading}>
<ShieldCheck size={24} aria-hidden="true" />
<h2 id="account-appeals-title">
{l("Account ban appeals", "Апелляции блокировок аккаунтов")}
</h2>
</div>
<p>
{l(
"Moderators and Admins can review appeals. Approving an appeal restores access for that ban. Deactivation and deletion remain Admin controls.",
"Модераторы и администраторы могут рассматривать апелляции. Одобрение восстанавливает доступ после этой блокировки. Деактивация и удаление доступны только администраторам.",
)}
</p>
<button
className="button"
disabled={busy || loading}
onClick={() => void run(refreshAppeals)}
>
<RefreshCw size={16} aria-hidden="true" />
{l("Refresh appeals", "Обновить апелляции")}
</button>
{loading && (
<p role="status">{l("Loading appeals…", "Загрузка апелляций…")}</p>
)}
{!loading && !error && appeals.length === 0 && (
<p>
{l(
"No appeals awaiting review.",
"Нет апелляций, ожидающих рассмотрения.",
)}
</p>
)}
<div className={styles.list}>
{appeals.map((appeal) => (
<article className={styles.record} key={appeal.id}>
<h3>{appeal.display_name}</h3>
<p className="muted">
<code>{appeal.owner_id}</code> ·{" "}
{new Date(appeal.created_at).toLocaleString(locale)}
</p>
{appeal.ban_reason && (
<p>
<strong>{l("Ban reason: ", "Причина блокировки: ")}</strong>
{appeal.ban_reason}
</p>
)}
<p className={styles.reason}>{appeal.reason}</p>
<label className="field">
{l(
"Decision reason (10–2000 characters)",
"Причина решения (10–2000 символов)",
)}
<textarea
minLength={10}
maxLength={2000}
rows={3}
value={reviewReasons[appeal.id] ?? ""}
onChange={(e) =>
setReviewReasons((current) => ({
...current,
[appeal.id]: e.target.value,
}))
}
disabled={busy}
/>
</label>
<div className={styles.actions}>
{(["approve", "reject"] as const).map((decision) => (
<button
className="button"
key={decision}
disabled={
busy ||
(reviewReasons[appeal.id] ?? "").trim().length < 10
}
onClick={() =>
void run(async () => {
await api("moderation/account-appeals/" + appeal.id, {
decision,
reason: reviewReasons[appeal.id],
});
await refreshAppeals();
if (administrator && searched) await find();
setMessage(
l("Appeal reviewed.", "Апелляция рассмотрена."),
);
})
}
>
{decision === "approve"
? l(
"Approve · restore access",
"Одобрить · восстановить доступ",
)
: l(
"Reject · keep ban",
"Отклонить · оставить блокировку",
)}
</button>
))}
</div>
</article>
))}
</div>
</section>
)}
</>
);
}

View File

@@ -12,6 +12,7 @@ import { ModeratorSettings } from "./ModeratorSettings";
import { ModeratorPrefixes } from "./ModeratorPrefixes";
import { isAdministrator, type StaffRole } from "@/shared/permissions";
import { DiscordIntegration } from "./DiscordIntegration";
import { AccountControls } from "./AccountControls";
import { PrometheusIntegration } from "./PrometheusIntegration";
type ReviewMap = {
id: string;
@@ -26,7 +27,11 @@ type ReviewMap = {
report_strikes: number;
banned_at: string | null;
};
export function Moderation() {
export function Moderation({
mode = "moderation",
}: {
mode?: "moderation" | "admin";
}) {
const { t, locale } = useI18n();
const [maps, setMaps] = useState<ReviewMap[]>([]),
[reports, setReports] = useState<
@@ -49,7 +54,7 @@ export function Moderation() {
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
const [role, setRole] = useState<StaffRole | null>(null);
const administrator = isAdministrator(role ?? undefined);
const administrator = mode === "admin" && isAdministrator(role ?? undefined);
const [reportPreview, setReportPreview] = useState<{
id: string;
panorama: string | null;
@@ -61,7 +66,7 @@ export function Moderation() {
maps: ReviewMap[];
reports: typeof reports;
appeals: Appeal[];
}>("moderation");
}>(mode === "admin" ? "moderation?view=admin" : "moderation");
setMaps(d.maps);
setRole(d.role);
setReports(d.reports);
@@ -103,7 +108,7 @@ export function Moderation() {
}
useEffect(() => {
load();
}, []);
}, [mode]);
async function decide(datasetId: string, decision: string) {
setBusy(true);
setError("");
@@ -122,6 +127,12 @@ export function Moderation() {
}
return (
<>
{role && (
<AccountControls
administrator={administrator}
showAppeals={mode === "moderation"}
/>
)}
{administrator && <ModeratorSettings />}
{administrator && <ModeratorPrefixes />}
{administrator && <DiscordIntegration />}
@@ -129,7 +140,7 @@ export function Moderation() {
{role === "moderator" && (
<p className="muted">
{t(
"Moderators can inspect and review pending maps and updates. Other controls require Admin permission.",
"Moderators can review pending maps, updates and account ban appeals. Other controls require Admin permission.",
)}
</p>
)}
@@ -142,78 +153,81 @@ export function Moderation() {
{!loading && !maps.length && !error && (
<p className="muted">{t("The review queue is clear.")}</p>
)}
{maps.map((m) => (
<article className="tool-card" key={m.id}>
<span className="badge">{t(statusMessage(m.state))}</span>
{m.update_state === "review" && (
<span className="badge">{t("Map update awaiting review")}</span>
)}
<h2>{m.name}</h2>
{m.report_strikes > 0 && (
<p className={m.banned_at ? "error-message" : "muted"}>
{m.report_strikes}
{t("/3 confirmed reports")}
{m.banned_at ? t(" · Banned; owner appeal required") : ""}
{maps
.filter((m) => mode !== "admin" || m.state !== "review")
.map((m) => (
<article className="tool-card" key={m.id}>
<span className="badge">{t(statusMessage(m.state))}</span>
{m.update_state === "review" && (
<span className="badge">{t("Map update awaiting review")}</span>
)}
<h2>{m.name}</h2>
{m.report_strikes > 0 && (
<p className={m.banned_at ? "error-message" : "muted"}>
{m.report_strikes}
{t("/3 confirmed reports")}
{m.banned_at ? t(" · Banned; owner appeal required") : ""}
</p>
)}
<p className="muted">
{m.source} · {m.panorama_count} {t("locations ·")} {m.description}
</p>
)}
<p className="muted">
{m.source} · {m.panorama_count} {t("locations ·")} {m.description}
</p>
{m.map && (
<Image
src={m.map}
width={512}
height={512}
alt={t("Map for moderator review")}
unoptimized
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
<label className="field">
{t("Review reason")}
<textarea
value={reasons[m.id] || ""}
onChange={(e) =>
setReasons((s) => ({ ...s, [m.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button className="button" onClick={() => setPreview(m.id)}>
{t("Inspect 360° locations")}
</button>
{m.state === "review" || m.update_state === "review" ? (
<>
<button
className="button primary"
disabled={busy}
onClick={() => decide(m.id, "approve")}
>
{m.update_state === "review"
? t("Approve update")
: t("Approve map")}
</button>
{m.map && (
<Image
src={m.map}
width={512}
height={512}
alt={t("Map for moderator review")}
unoptimized
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
<label className="field">
{t("Review reason")}
<textarea
value={reasons[m.id] || ""}
onChange={(e) =>
setReasons((s) => ({ ...s, [m.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button className="button" onClick={() => setPreview(m.id)}>
{t("Inspect 360° locations")}
</button>
{mode === "moderation" &&
(m.state === "review" || m.update_state === "review") ? (
<>
<button
className="button primary"
disabled={busy}
onClick={() => decide(m.id, "approve")}
>
{m.update_state === "review"
? t("Approve update")
: t("Approve map")}
</button>
<button
className="button"
disabled={busy || !reasons[m.id]?.trim()}
onClick={() => decide(m.id, "reject")}
>
{t("Return with feedback")}
</button>
</>
) : null}
{administrator && m.state === "published" && (
<button
className="button"
disabled={busy || !reasons[m.id]?.trim()}
onClick={() => decide(m.id, "reject")}
disabled={busy}
onClick={() => decide(m.id, "unpublish")}
>
{t("Return with feedback")}
{t("Unpublish")}
</button>
</>
) : null}
{administrator && m.state === "published" && (
<button
className="button"
disabled={busy}
onClick={() => decide(m.id, "unpublish")}
>
{t("Unpublish")}
</button>
)}
</div>
</article>
))}
)}
</div>
</article>
))}
{administrator && reports.length > 0 && (
<section className="token-box">
<h2>{t("Community reports")}</h2>

View File

@@ -3,7 +3,8 @@ import "server-only";
import Link from "next/link";
import { Compass, ArrowUpRight } from "lucide-react";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { user } from "@/lib/supabase";
import { rawUser, admin, required } from "@/lib/supabase";
import { canReviewMaps, isAdministrator } from "@/shared/permissions";
import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
import { ReportBugButton } from "./ReportBugButton";
@@ -12,7 +13,7 @@ import { CommunityLinks } from "./CommunityLinks";
import { forumUrl } from "@/lib/forum";
export async function Header() {
const { t } = await getI18n();
const account = await user();
const account = await rawUser();
return (
<header className="site-header">
<div className="header-inner">
@@ -35,6 +36,17 @@ export async function Header() {
export async function Footer() {
const { t } = await getI18n();
const forum = forumUrl();
const account = await rawUser();
const result = account
? await admin()
.from("profiles")
.select("role,account_status")
.eq("id", account.id)
.maybeSingle()
: null;
if (result?.error) required(result);
const role =
result?.data?.account_status === "active" ? result.data.role : undefined;
return (
<footer className="site-footer">
<div>
@@ -60,7 +72,10 @@ export async function Footer() {
{t("Status")}
</a>
<Link href="/leaderboard">{t("Leaderboard")}</Link>
<Link href="/moderation">{t("Moderation")}</Link>
{canReviewMaps(role) && (
<Link href="/moderation">{t("Moderation Tools")}</Link>
)}
{isAdministrator(role) && <Link href="/admin">{t("Admin Tools")}</Link>}
</div>
<p className="disclaimer">
{t(

32
docs/account-controls.md Normal file
View File

@@ -0,0 +1,32 @@
# Account access and ban appeals
If your account is restricted, sign in as usual. MineGeo will take you to **Account access & appeals**, where you can read the reason. You can also open **/ban/appeal** directly.
## Deactivated or banned?
- **Deactivated:** an Admin has temporarily disabled account access. An Admin must restore it; this does not use the ban appeal queue. Contact the team through the Discord community link in the footer.
- **Banned:** normal account access is blocked. You can submit an appeal explaining why the decision should be reconsidered.
- **Deletion pending:** an Admin has started removing the account. Access remains blocked while the action completes. Only an Admin can finish it.
While restricted, you can read the docs, check this page, sign out, and use the sign-in or password recovery flow. You cannot play through that account, upload maps, use its API keys, or use staff tools. Existing upload and API keys are revoked and existing games and multiplayer participation are ended. Restoring access does not reactivate old keys; create new ones when needed.
## Send an appeal
1. Sign in to the banned account and open **/ban/appeal**.
2. Read the ban reason. Explain what happened and why the ban should be reconsidered in **10–2000 characters**.
3. Click **Send appeal**. You can send **one appeal for each ban**, so include the useful details before sending.
4. Return to this page and click **Refresh status** to see the decision.
Both Moderators and Admins can review account ban appeals. An approval restores access for the ban being reviewed. A rejection keeps the ban. Staff leave a reason you can read on the same page. You cannot submit the same ban again after a rejection; contact the team if you have new information.
If an Admin changes the restriction while an appeal is waiting, the old appeal closes. An old approval cannot override a newer restriction.
## Deleted accounts
Permanent deletion removes sign-in, the profile, credentials and account appeals. It cannot be appealed or recovered through this page. Shared maps and community history can remain without an account owner; deletion does not erase the worlds other players use.
## Staff controls
Open **Admin Tools** from the footer or go to **/admin**. This separate page is available only to Admins. Admins can search accounts, deactivate, ban, restore access, and permanently delete accounts. Every action needs a reason. Permanent deletion also requires typing the exact account ID. Admin accounts are protected from these controls.
Open **Moderation Tools** at **/moderation** to review maps and account ban appeals. Moderators and Admins can access this page. Moderators cannot deactivate, ban, delete, or directly restore arbitrary accounts.

View File

@@ -1,5 +1,10 @@
# Deploy MineGeo
Account restrictions and appeals require `20261013070138_account_controls_and_appeals.sql` before deploying.
No new environment variables are needed. Admin Tools is at `/admin` (Admin only), Moderation Tools is at `/moderation`
(Moderators and Admins); account status and appeals are at `/ban/appeal`. The built-in
guide is `/docs?page=account-bans`. See [Account controls](account-controls.md).
The scoped developer API requires `20261012124943_developer_api_keys.sql`.
No additional environment variables are needed. The standalone panel is
`/dev/api`, `/api/dev` redirects there, and the versioned read-only API is

View File

@@ -1,7 +1,14 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
import { admin, authClient, required, user } from "./supabase";
import {
admin,
authClient,
required,
user,
rawUser,
accountState,
} from "./supabase";
import { accountProfile } from "./profile";
import {
authActionSchema,
@@ -26,12 +33,31 @@ export async function accountApi(
path: string[],
context: Context,
) {
if (
path[0] === "auth" &&
path[1] === "logout" &&
path.length === 2 &&
request.method === "POST"
) {
const client = await authClient();
const { error } = await client.auth.signOut({ scope: "local" });
if (error) throw new Error("Could not sign out. Please try again.");
const response = json({ ok: true });
for (const name of ["mg_guest", "mg_multiplayer"])
response.cookies.set(name, "", {
path: "/",
maxAge: 0,
httpOnly: true,
sameSite: "lax",
});
return response;
}
if (
path[0] === "auth" &&
path[1] === "initial-password" &&
path.length === 2
) {
const account = await user();
const account = await rawUser();
if (!account?.email || !account.email_confirmed_at)
return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET") {
@@ -126,7 +152,7 @@ export async function accountApi(
: next,
);
if (body.action === "login") {
const { error } = await client.auth.signInWithPassword({
const { data, error } = await client.auth.signInWithPassword({
email: body.email,
password: body.password,
});
@@ -140,7 +166,12 @@ export async function accountApi(
},
401,
);
return json({ next });
return json({
next:
data.user && (await accountState(data.user.id)) !== "active"
? "/ban/appeal"
: next,
});
}
if (body.action === "signup") {
const { data, error } = await client.auth.signUp({
@@ -175,7 +206,7 @@ export async function accountApi(
});
}
if (path[1] !== "password") return json({ error: "Not found" }, 404);
const account = await user();
const account = await rawUser();
if (!account?.email)
return json({ error: "Log in before changing your password" }, 401);
const body = passwordUpdateSchema.parse(await context.jsonBody(request));

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

View File

@@ -2,7 +2,8 @@ import "server-only";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required, user } from "./supabase";
import { admin, required, user, assertAccountActive } from "./supabase";
import { accountControlsApi } from "./account-controls-api";
import {
datasetSchema,
faces,
@@ -64,6 +65,7 @@ async function contributor(request: NextRequest, browserOnly = false) {
owner = row.owner_id;
} else owner = (await user())?.id;
if (!owner) throw new Error("Sign in to continue");
await assertAccountActive(owner);
const { error } = await admin()
.from("profiles")
.upsert({ id: owner }, { onConflict: "id", ignoreDuplicates: true });
@@ -97,7 +99,7 @@ async function limit(key: string, count: number, seconds: number) {
}
async function ownedDataset(request: NextRequest, id: string) {
const owner = await contributor(request);
return required(
const dataset = required(
await admin()
.from("datasets")
.select("*")
@@ -105,6 +107,8 @@ async function ownedDataset(request: NextRequest, id: string) {
.eq("owner_id", owner)
.single(),
);
// The ownership predicate above proves this is a current account's map.
return { ...dataset, owner_id: owner };
}
async function session(request: NextRequest, id: string) {
const record = required(
@@ -119,8 +123,16 @@ async function session(request: NextRequest, id: string) {
export async function handle(request: NextRequest, path: string[]) {
try {
verifyOrigin(request);
if (
(path[0] === "ban" && path[1] === "appeal") ||
(path[0] === "moderation" &&
["accounts", "account-appeals"].includes(path[1]))
)
return await accountControlsApi(request, path, { jsonBody, limit });
if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit });
// Check signed-in identities before any guest-cookie fallback (games/rooms).
if (path[0] !== "health") await user();
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
if (path[0] === "bug-reports")
@@ -733,7 +745,13 @@ export async function handle(request: NextRequest, path: string[]) {
await client
.from("profiles")
.select("id,display_name")
.in("id", [...new Set(rows.map((c) => c.owner_id))]),
.in("id", [
...new Set(
rows
.map((c) => c.owner_id)
.filter((id): id is string => id !== null),
),
]),
)
: [];
return NextResponse.json({
@@ -1066,12 +1084,14 @@ export async function handle(request: NextRequest, path: string[]) {
.is("parent_dataset_id", null)
.in("state", ["review", "published", "unpublished"])
.order("created_at");
if (!isAdministrator(role))
const adminView = request.nextUrl.searchParams.get("view") === "admin";
if (adminView) requireAdministrator(role);
if (!adminView)
mapQuery = mapQuery.or(
"state.eq.review,and(state.eq.published,update_state.eq.review)",
);
const maps = required(await mapQuery);
const reports = isAdministrator(role)
const reports = adminView
? required(
await client
.from("reports")
@@ -1080,7 +1100,7 @@ export async function handle(request: NextRequest, path: string[]) {
.order("created_at"),
)
: [];
const appeals = isAdministrator(role)
const appeals = adminView
? required(
await client
.from("map_appeals")

View File

@@ -16,7 +16,7 @@ export type MapListing = {
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
panorama_count: number;
server_id: string;
owner_id: string;
owner_id: string | null;
cover_path: string | null;
map_path: string | null;
rejection_reason: string | null;
@@ -66,7 +66,13 @@ export async function catalog() {
.select(
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
.in("id", [
...new Set(
maps
.map((m) => m.owner_id)
.filter((id): id is string => id !== null),
),
]),
)
: [];
const signed = await mediaUrls(
@@ -74,7 +80,9 @@ export async function catalog() {
);
return maps.map((m, index) => ({
...m,
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
credit:
owners.find((p) => p.id === m.owner_id)?.display_name ??
(m.owner_id ? "Explorer" : "Deleted player"),
creditGlow: owners.find((p) => p.id === m.owner_id)?.name_glow ?? false,
creditAppearance: owners.find((p) => p.id === m.owner_id),
image: signed[index * 2] ?? undefined,
@@ -94,18 +102,22 @@ export async function getMap(id: string) {
if (!result.data) return null;
const map = result.data as unknown as MapListing;
const [owner, signed] = await Promise.all([
admin()
.from("profiles")
.select(
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.eq("id", map.owner_id)
.maybeSingle(),
map.owner_id
? admin()
.from("profiles")
.select(
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.eq("id", map.owner_id)
.maybeSingle()
: Promise.resolve({ data: null }),
mediaUrls([map.cover_path, map.map_path]),
]);
return {
...map,
credit: owner.data?.display_name ?? "Explorer",
credit:
owner.data?.display_name ??
(map.owner_id ? "Explorer" : "Deleted player"),
creditGlow: owner.data?.name_glow ?? false,
creditAppearance: owner.data ?? undefined,
image: signed[0],

View File

@@ -36,11 +36,13 @@ async function announcementSource(
.maybeSingle();
if (row.error) required(row);
if (!row.data) return null;
const owner = await db
.from("profiles")
.select("display_name")
.eq("id", row.data.owner_id)
.maybeSingle();
const owner = row.data.owner_id
? await db
.from("profiles")
.select("display_name")
.eq("id", row.data.owner_id)
.maybeSingle()
: { data: null, error: null };
if (owner.error) required(owner);
return {
id: row.data.id,

24
lib/staff-page.ts Normal file
View File

@@ -0,0 +1,24 @@
import "server-only";
import { notFound, redirect } from "next/navigation";
import { admin, required, user } from "./supabase";
import { canReviewMaps, isAdministrator } from "@/shared/permissions";
export async function staffPage(adminOnly = false) {
const account = await user();
const destination = adminOnly ? "/admin" : "/moderation";
if (!account) redirect("/log-in?next=" + encodeURIComponent(destination));
const profile = required(
await admin()
.from("profiles")
.select("role,account_status")
.eq("id", account.id)
.single(),
);
if (
profile.account_status !== "active" ||
!canReviewMaps(profile.role) ||
(adminOnly && !isAdministrator(profile.role))
)
notFound();
return profile;
}

View File

@@ -38,7 +38,8 @@ export async function authClient() {
},
);
}
export const user = cache(async function user() {
/** Authentication identity only. Restricted accounts use this solely for appeals/auth. */
export const rawUser = cache(async function rawUser() {
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
@@ -50,6 +51,27 @@ export const user = cache(async function user() {
} = await client.auth.getUser();
return user;
});
export const accountState = cache(async function accountState(id: string) {
const result = await admin()
.from("profiles")
.select("account_status")
.eq("id", id)
.maybeSingle();
checkServiceError(result.error);
return result.data?.account_status ?? "active";
});
export async function assertAccountActive(id: string) {
const status = await accountState(id);
if (status !== "active") {
const { restrictionMessage } = await import("@/shared/account-controls");
throw new ServiceError(restrictionMessage(status), 403);
}
}
export const user = cache(async function user() {
const account = await rawUser();
if (account) await assertAccountActive(account.id);
return account;
});
export function required<T>(result: {
data: T;
error: { message: string; code?: string } | null;

View File

@@ -1,6 +1,7 @@
import { createServerClient } from "@supabase/ssr";
import { NextRequest, NextResponse } from "next/server";
import { legacyRecoveryToken } from "./shared/legacy-recovery";
import { restrictionPageAllowed } from "./shared/account-controls";
export async function proxy(request: NextRequest) {
const recoveryToken = legacyRecoveryToken(request.nextUrl.pathname);
if (recoveryToken) {
@@ -34,7 +35,31 @@ export async function proxy(request: NextRequest) {
},
},
);
await client.auth.getUser();
const {
data: { user },
} = await client.auth.getUser();
const path = request.nextUrl.pathname;
if (user && !path.startsWith("/api/") && !restrictionPageAllowed(path)) {
const { data, error } = await client
.from("profiles")
.select("account_status")
.eq("id", user.id)
.maybeSingle();
if (error)
return new NextResponse(
"Account access could not be verified. Please try again.",
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
if (data && data.account_status !== "active") {
const destination = new URL("/ban/appeal", request.url);
const redirect = NextResponse.redirect(destination);
response.cookies
.getAll()
.forEach((cookie) => redirect.cookies.set(cookie));
redirect.headers.set("Cache-Control", "private, no-store");
return redirect;
}
}
return response;
}
export const config = {

View File

@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
const id = (n) => `00000000-0000-4000-8000-${String(n).padStart(12,"0")}`;
const [admin, moderator, owner, other, anotherAdmin] = [1,2,3,4,5].map(id);
const hash = "a".repeat(64);
const reason = "Verified account restriction reason.";
const value = async (sql,args=[]) => (await db.query(sql,args)).rows[0]?.value;
const action = (user,op,reviewer=admin) => value("select public.admin_account_action($1,$2,$3,$4) value",[reviewer,user,op,reason]);
const appeal = (user=owner) => value("select public.submit_account_appeal($1,$2) value",[user,"Please independently reconsider my account ban."]);
const review = (a,decision,reviewer=moderator) => db.query("select public.review_account_appeal($1,$2,$3,$4)",[reviewer,a,decision,"Independent review with detailed feedback."]);
const profile = (user=owner) => value("select to_jsonb(p) value from public.profiles p where id=$1",[user]);
try {
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls; create role supabase_auth_admin bypassrls;
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for(const file of (await readdir("supabase/migrations")).filter(f=>f.endsWith(".sql")).sort())
await db.exec(await readFile(`supabase/migrations/${file}`,"utf8"));
await db.exec("grant select,insert,update,delete on all tables in schema public to service_role;");
for(const user of [admin,moderator,owner,other,anotherAdmin]) await db.query("insert into auth.users values($1)",[user]);
await db.query("insert into public.profiles(id,role) values($1,'admin'),($2,'moderator'),($3,'contributor'),($4,'contributor'),($5,'admin')",[admin,moderator,owner,other,anotherAdmin]);
assert.equal((await profile()).account_status,"active");
// Neither anonymous callers nor logged-in browser roles get server-only powers.
for(const role of ["anon","authenticated"]) {
await db.exec(`set role ${role}`);
for(const sql of ["select * from public.account_appeals","select * from public.account_control_events",
`select public.admin_account_action('${admin}','${owner}','ban','${reason}')`,
`select public.submit_account_appeal('${owner}','${reason}')`,
`select public.review_account_appeal('${admin}','${id(99)}','approve','${reason}')`])
await assert.rejects(db.query(sql),/permission denied/);
await db.exec("reset role");
}
// Simulate a future browser profile edit policy: protected status fields stay server-only.
await db.exec("grant select,update on public.profiles to authenticated; create policy fixture_profiles_edit on public.profiles for all to authenticated using(true) with check(true); set role authenticated;");
await assert.rejects(db.query("update public.profiles set account_status='banned' where id=$1",[owner]),/managed by Admins/);
await assert.rejects(db.query("update public.profiles set account_status_version=10 where id=$1",[owner]),/managed by Admins/);
await db.exec("reset role; drop policy fixture_profiles_edit on public.profiles;");
await db.exec("set role service_role");
await assert.rejects(action(owner,"ban",moderator),/Active Admin/);
await assert.rejects(action(owner,"ban",other),/Active Admin/);
await assert.rejects(action(admin,"ban"),/own account/);
for(const op of ["ban","deactivate","restore","prepare_delete"]) await assert.rejects(action(anotherAdmin,op),/protected/);
await assert.rejects(action(id(99),"ban"),/Account not found/);
await assert.rejects(action(owner,"unknown"),/account action/);
await assert.rejects(appeal(),/Only banned/);
// A published map and moderation history survive the Auth user's deletion.
const map=id(20),revision=id(21),room=id(22),player=id(23),game=id(24);
await db.query("insert into public.datasets(id,owner_id,name,source,state,bounds) values($1,$2,'Retained map','fabric','published','{}')",[map,owner]);
await db.query("insert into public.revisions(id,dataset_id,manifest,asset_root) values($1,$2,'{}','fixture')",[revision,map]);
await db.query("update public.datasets set revision_id=$1 where id=$2",[revision,map]);
await db.query("insert into public.review_decisions(dataset_id,reviewer_id,decision) values($1,$2,'approve')",[map,owner]);
await db.query("insert into public.reports(dataset_id,owner_id,reviewer_id,reason) values($1,$2,$2,'History')",[map,owner]);
await db.query("insert into public.map_appeals(dataset_id,owner_id,reviewer_id,report_cycle,reason) values($1,$2,$2,0,'Historical map appeal')",[map,owner]);
await db.query("insert into public.contributor_tokens(owner_id,token_hash,label) values($1,'uploadfixture','fixture')",[owner]);
await db.query("insert into public.pairing_codes(device_hash,user_code,owner_id,expires_at,token_value) values('device','PAIR',$1,clock_timestamp()+interval '1 day','secret')",[owner]);
const key=await value("select public.developer_key_create($1,'Test',array['maps:read'],$2,'mg_dev_fixture',1) value",[owner,hash]);
await db.query("insert into public.game_sessions(id,revision_id,owner_id,access_hash,difficulty) values($1,$2,$3,'old-solo-secret','easy')",[game,revision,owner]);
await db.query("insert into public.multiplayer_rooms(id,revision_id,host_player_id,difficulty,player_limit) values($1,$2,$3,'easy',4)",[room,revision,player]);
await db.query("insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id) values($1,$2,'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd','Fixture',$3)",[player,room,owner]);
assert.deepEqual(await action(owner,"ban"),{id:owner,status:"banned",statusVersion:1});
assert.equal((await profile()).account_reason,reason);
assert.equal(await value("select revoked value from public.contributor_tokens where owner_id=$1",[owner]),true);
assert.equal(await value("select count(*)::int value from public.pairing_codes where owner_id=$1",[owner]),0);
assert.ok(await value("select revoked_at value from public.developer_api_keys where id=$1",[key.id]));
assert.deepEqual(await value("select public.developer_key_consume($1) value",[hash]),{ok:false,error:"invalid_key"});
await assert.rejects(value("select public.developer_key_create($1,'Denied',array['maps:read'],$2,'mg_dev_fixture2',1) value",[owner,"b".repeat(64)]),/Account required/);
assert.notEqual(await value("select access_hash value from public.game_sessions where id=$1",[game]),"old-solo-secret");
assert.equal(await value("select status value from public.multiplayer_rooms where id=$1",[room]),"closed");
const departed=await value("select to_jsonb(p) value from public.multiplayer_players p where id=$1",[player]);
assert.equal(departed.has_left,true); assert.notEqual(departed.access_hash,"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
await assert.rejects(action(owner,"ban"),/already has/);
const first=await appeal();
await assert.rejects(appeal(),/already been submitted/);
await assert.rejects(review(first,"approve",other),/Active Moderator/);
await review(first,"reject");
assert.equal((await profile()).account_status,"banned");
await assert.rejects(appeal(),/already been submitted/);
await assert.rejects(review(first,"approve"),/no longer awaiting/);
await action(owner,"restore");
assert.equal((await profile()).account_status,"active");
assert.equal((await profile()).account_reason,null);
assert.deepEqual(await value("select public.developer_key_consume($1) value",[hash]),{ok:false,error:"invalid_key"},"Restore never resurrects revoked credentials");
await action(owner,"deactivate");
await assert.rejects(appeal(),/Only banned/);
await action(owner,"ban");
const stale=await appeal();
await action(owner,"deactivate");
assert.equal(await value("select state value from public.account_appeals where id=$1",[stale]),"rejected");
await action(owner,"ban");
await assert.rejects(review(stale,"approve"),/no longer awaiting/);
const current=await appeal();
// A banned moderator cannot review; fresh DB status wins over previous role.
await action(moderator,"deactivate");
await assert.rejects(review(current,"approve"),/Active Moderator/);
await action(moderator,"restore");
await review(current,"approve");
assert.equal((await profile()).account_status,"active");
assert.equal(await value("select state value from public.account_appeals where id=$1",[current]),"approved");
// Defense in depth still checks current owner status if a credential was not revoked.
await db.query("update public.profiles set account_status='banned' where id=$1",[other]);
await db.query("insert into public.developer_api_keys(owner_id,name,prefix,token_hash,scopes,expires_at) values($1,'Old','mg_dev_old',$2,array['maps:read'],clock_timestamp()+interval '1 day')",[other,"c".repeat(64)]);
assert.deepEqual(await value("select public.developer_key_consume($1) value",["c".repeat(64)]),{ok:false,error:"invalid_key"});
await db.query("update public.profiles set account_status='active' where id=$1",[other]);
await value("select public.configure_prometheus($1,$2) value",[admin,"e".repeat(64)]);
await db.query("update public.profiles set account_status='deactivated' where id=$1",[admin]);
await assert.rejects(action(other,"ban"),/Active Admin/);
await assert.rejects(value("select public.prometheus_status($1) value",[admin]),/Admin permission/);
assert.equal(await value("select public.read_prometheus_metrics($1) value",["e".repeat(64)]),null);
await db.query("update public.profiles set account_status='active' where id=$1",[admin]);
const pending=await action(owner,"prepare_delete");
assert.equal(pending.status,"deleting");
assert.deepEqual(await action(owner,"prepare_delete"),pending,"Failed Auth deletion can safely be retried");
await assert.rejects(appeal(),/Only banned/);
await db.exec("reset role");
await db.query("delete from auth.users where id=$1",[owner]);
assert.equal(await profile(),undefined);
assert.equal(await value("select owner_id value from public.datasets where id=$1",[map]),null);
assert.equal(await value("select state value from public.datasets where id=$1",[map]),"published");
assert.equal(await value("select reviewer_id value from public.review_decisions where dataset_id=$1",[map]),null);
const retained=await value("select to_jsonb(r) value from public.reports r where dataset_id=$1",[map]);
assert.equal(retained.owner_id,null); assert.equal(retained.reviewer_id,null);
const mapAppeal=await value("select to_jsonb(a) value from public.map_appeals a where dataset_id=$1",[map]);
assert.equal(mapAppeal.owner_id,null); assert.equal(mapAppeal.reviewer_id,null);
assert.equal(await value("select owner_id value from public.game_sessions where id=$1",[game]),null);
assert.equal(await value("select account_id value from public.multiplayer_players where id=$1",[player]),null);
assert.equal(await value("select count(*)::int value from public.account_appeals where owner_id=$1",[owner]),0);
assert.equal(await value("select count(*)::int value from public.developer_api_keys where owner_id=$1",[owner]),0);
assert.equal(await value("select count(*)::int value from public.contributor_tokens where owner_id=$1",[owner]),0);
assert.ok(await value("select count(*)::int value from public.account_control_events where owner_id is null")>0);
// Supabase Auth uses its own internal role. FK cleanup must not be blocked by
// browser-only field protection when deleting a former decision reviewer.
await db.exec("grant usage on schema auth to supabase_auth_admin; grant select,delete on auth.users to supabase_auth_admin; set role supabase_auth_admin;");
await db.query("delete from auth.users where id=$1",[admin]);
await db.exec("reset role");
assert.equal((await profile(moderator)).account_changed_by,null);
console.log("Account controls SQL checks passed: grants, protected fields, fresh role checks, protected Admins, credential/session invalidation, one appeal per ban, stale appeal rejection, Moderator review, deletion retry, retained maps/history and cascading private credentials.");
} finally { await db.close(); }

View File

@@ -0,0 +1,65 @@
import { z } from "zod";
export const accountStatuses = [
"active",
"deactivated",
"banned",
"deleting",
] as const;
export type AccountStatus = (typeof accountStatuses)[number];
export const accountReason = z.string().trim().min(10).max(2000);
export const accountActionInput = z
.object({
action: z.enum(["deactivate", "ban", "restore", "delete"]),
reason: accountReason,
confirmation: z.string().uuid().optional(),
})
.strict();
export const accountAppealInput = z.object({ reason: accountReason }).strict();
export const accountAppealReviewInput = z
.object({
decision: z.enum(["approve", "reject"]),
reason: accountReason,
})
.strict();
export function restrictionMessage(status: string) {
return status === "banned"
? "Your account is banned. Visit /ban/appeal to read the reason and appeal."
: status === "deleting"
? "Account deletion is pending. Visit /ban/appeal for account status."
: "Your account is deactivated. Visit /ban/appeal for account status.";
}
/** Restricted accounts retain only the pages needed to understand/appeal access. */
export function restrictionPageAllowed(path: string) {
return (
path === "/ban/appeal" ||
path === "/docs" ||
path === "/log-in" ||
path === "/auth/callback" ||
path === "/auth/reset-password"
);
}
export type ManagedAccount = {
id: string;
display_name: string;
role: string;
account_status: AccountStatus;
account_reason: string | null;
account_changed_at: string | null;
status_version: number;
};
export type AccountAppealRecord = {
ban_reason?: string | null;
id: string;
owner_id: string;
display_name: string;
status_version: number;
reason: string;
state: "open" | "approved" | "rejected";
created_at: string;
review_reason: string | null;
reviewed_at: string | null;
};

View File

@@ -25,7 +25,7 @@ type ReportModerationFields = {
type AppealRecord = {
id: string;
dataset_id: string;
owner_id: string;
owner_id: string | null;
report_cycle: number;
reason: string;
state: string;
@@ -35,6 +35,25 @@ type AppealRecord = {
review_reason: string | null;
};
type AccountStatusFields = {
account_status: "active" | "deactivated" | "banned" | "deleting";
account_reason: string | null;
account_changed_at: string | null;
account_changed_by: string | null;
account_status_version: number;
};
type AccountAppealRecord = {
id: string;
owner_id: string;
status_version: number;
reason: string;
state: "open" | "approved" | "rejected";
created_at: string;
reviewer_id: string | null;
reviewed_at: string | null;
review_reason: string | null;
};
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
@@ -43,6 +62,37 @@ export type Database = {
};
public: {
Tables: {
account_appeals: {
Row: AccountAppealRecord;
Insert: Partial<AccountAppealRecord> &
Pick<AccountAppealRecord, "owner_id" | "status_version" | "reason">;
Update: Partial<AccountAppealRecord>;
Relationships: [];
};
account_control_events: {
Row: {
id: string;
owner_id: string | null;
reviewer_id: string | null;
action: string;
previous_status: string;
new_status: string;
status_version: number;
reason: string;
created_at: string;
};
Insert: {
owner_id?: string | null;
reviewer_id?: string | null;
action: string;
previous_status: string;
new_status: string;
status_version: number;
reason: string;
};
Update: { owner_id?: string | null; reviewer_id?: string | null };
Relationships: [];
};
discord_announcements: {
Row: import("./discord").DiscordAnnouncement;
Insert: Partial<import("./discord").DiscordAnnouncement> &
@@ -307,7 +357,7 @@ export type Database = {
id: string;
map_path: string | null;
name: string;
owner_id: string;
owner_id: string | null;
panorama_count: number;
parent_dataset_id: string | null;
rejection_reason: string | null;
@@ -330,7 +380,7 @@ export type Database = {
id?: string;
map_path?: string | null;
name: string;
owner_id: string;
owner_id: string | null;
panorama_count?: number;
parent_dataset_id?: string | null;
rejection_reason?: string | null;
@@ -353,7 +403,7 @@ export type Database = {
id?: string;
map_path?: string | null;
name?: string;
owner_id?: string;
owner_id?: string | null;
panorama_count?: number;
parent_dataset_id?: string | null;
rejection_reason?: string | null;
@@ -489,7 +539,7 @@ export type Database = {
Relationships: [];
};
profiles: {
Row: {
Row: AccountStatusFields & {
glow_color: string;
bug_finder: boolean;
custom_prefix: string | null;
@@ -500,7 +550,7 @@ export type Database = {
id: string;
role: string;
};
Insert: {
Insert: Partial<AccountStatusFields> & {
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
@@ -511,7 +561,7 @@ export type Database = {
id: string;
role?: string;
};
Update: {
Update: Partial<AccountStatusFields> & {
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
@@ -702,6 +752,28 @@ export type Database = {
[_ in never]: never;
};
Functions: {
admin_account_action: {
Args: {
p_reviewer: string;
p_user: string;
p_action: string;
p_reason: string;
};
Returns: Json;
};
submit_account_appeal: {
Args: { p_owner: string; p_reason: string };
Returns: string;
};
review_account_appeal: {
Args: {
p_reviewer: string;
p_appeal: string;
p_decision: string;
p_reason: string;
};
Returns: undefined;
};
developer_keys_list: { Args: { p_owner: string }; Returns: Json };
developer_key_create: {
Args: {

View File

@@ -884,5 +884,17 @@
"Metrics access disabled. Existing scrape credentials no longer work.": "Доступ к метрикам отключён. Прежние данные доступа больше не работают.",
"Copied to clipboard.": "Скопировано в буфер обмена.",
"Could not copy. Select the text and copy it manually.": "Не удалось скопировать. Выделите текст и скопируйте его вручную.",
"Status": "Статус сервисов"
"Status": "Статус сервисов",
"Admin & Moderation Tools": "Администрирование и модерация",
"Admin & Moderation Tools.": "Администрирование и модерация.",
"Manage the community, review maps and account appeals, and configure Admin settings.": "Управляйте сообществом, проверяйте карты и апелляции и меняйте настройки администратора.",
"Moderators can review pending maps, updates and account ban appeals. Other controls require Admin permission.": "Модераторы могут проверять карты, обновления и апелляции блокировок аккаунтов. Остальные действия требуют прав администратора.",
"Admin Tools": "Инструменты администратора",
"Admin Tools.": "Инструменты администратора.",
"Moderation Tools": "Инструменты модерации",
"Moderation Tools.": "Инструменты модерации.",
"Open Admin Tools": "Открыть инструменты администратора",
"Open Moderation Tools": "Открыть инструменты модерации",
"Review pending maps, map updates and account ban appeals.": "Рассматривайте новые карты, обновления карт и апелляции блокировок аккаунтов.",
"Manage accounts, site settings, integrations and community reports.": "Управляйте аккаунтами, настройками сайта, интеграциями и жалобами сообщества."
}

View File

@@ -63,5 +63,10 @@
"title": "Community and badges",
"slug": "staff",
"markdown": "# Community and badges\n\nMeet other explorers, recognize the people helping the community, and make your account feel like yours.\n\n## Join Discord\n\nUse the Discord button at the bottom of MineGeo, or [join the community here](https://discord.gg/Aer4ZugKWS).\n\nNewly approved maps may be announced there. Hosts can also share open multiplayer parties to the community's party channel. Open an invite to join while there is space.\n\n## What do the name badges mean?\n\n- **Moderator, with a shield:** someone who can check maps and approve them for public play.\n- **Admin, with a crown:** someone who can also handle community reports and other site management.\n- **Bug finder:** someone recognized for helping find a confirmed bug.\n- **Custom prefix:** extra text and an icon assigned by an Admin.\n\nA decorative custom prefix does not give someone moderation powers. Copying another player's name does not copy their badges.\n\n## Can I get a glow?\n\nSupport may award one for a confirmed bug report. Describe the real problem through **Report a bug**. A reward is granted by staff; it is not automatic for every report.\n\nIf you have an active glow, open **Settings → Username glow**, choose a color, and save it. You can keep an earned Bug finder badge even if the glow is later removed.\n\n## Be a good party member\n\nShare invites thoughtfully, keep chat friendly, and avoid spoiling the location before everyone has guessed. Report content that needs attention instead of starting an argument.\n\n> The real treasure was the friends who also guessed the wrong mountain. ⛰️"
},
{
"slug": "account-bans",
"title": "Account access and ban appeals",
"markdown": "# Account access and ban appeals\n\nIf your account is restricted, sign in as usual. MineGeo will take you to **Account access & appeals**, where you can read the reason. You can also open **/ban/appeal** directly.\n\n## Deactivated or banned?\n\n- **Deactivated:** an Admin has temporarily disabled account access. An Admin must restore it; this does not use the ban appeal queue. Contact the team through the Discord community link in the footer.\n- **Banned:** normal account access is blocked. You can submit an appeal explaining why the decision should be reconsidered.\n- **Deletion pending:** an Admin has started removing the account. Access remains blocked while the action completes. Only an Admin can finish it.\n\nWhile restricted, you can read the docs, check this page, sign out, and use the sign-in or password recovery flow. You cannot play through that account, upload maps, use its API keys, or use staff tools. Existing upload and API keys are revoked and existing games and multiplayer participation are ended. Restoring access does not reactivate old keys; create new ones when needed.\n\n## Send an appeal\n\n1. Sign in to the banned account and open **/ban/appeal**.\n2. Read the ban reason. Explain what happened and why the ban should be reconsidered in **10–2000 characters**.\n3. Click **Send appeal**. You can send **one appeal for each ban**, so include the useful details before sending.\n4. Return to this page and click **Refresh status** to see the decision.\n\nBoth Moderators and Admins can review account ban appeals. An approval restores access for the ban being reviewed. A rejection keeps the ban. Staff leave a reason you can read on the same page. You cannot submit the same ban again after a rejection; contact the team if you have new information.\n\nIf an Admin changes the restriction while an appeal is waiting, the old appeal closes. An old approval cannot override a newer restriction.\n\n## Deleted accounts\n\nPermanent deletion removes sign-in, the profile, credentials and account appeals. It cannot be appealed or recovered through this page. Shared maps and community history can remain without an account owner; deletion does not erase the worlds other players use.\n\n## Staff controls\n\nOpen **Admin Tools** from the footer or go to **/admin**. This separate page is available only to Admins. Admins can search accounts, deactivate, ban, restore access, and permanently delete accounts. Every action needs a reason. Permanent deletion also requires typing the exact account ID. Admin accounts are protected from these controls.\n\nOpen **Moderation Tools** at **/moderation** to review maps and account ban appeals. Moderators and Admins can access this page. Moderators cannot deactivate, ban, delete, or directly restore arbitrary accounts."
}
]

View File

@@ -0,0 +1,188 @@
-- Account restrictions are fresh database state, never user-editable JWT claims.
alter table public.profiles
add column account_status text not null default 'active' check(account_status in ('active','deactivated','banned','deleting')),
add column account_reason text check(account_reason is null or length(btrim(account_reason)) between 10 and 2000),
add column account_changed_at timestamptz,
add column account_changed_by uuid references public.profiles(id) on delete set null,
add column account_status_version integer not null default 0 check(account_status_version>=0);
create index profiles_account_status on public.profiles(account_status) where account_status<>'active';
create function public.protect_account_status() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
-- Auth's FK cleanup may clear a deleted reviewer's reference, never status.
if tg_op='UPDATE' and current_user='supabase_auth_admin'
and new.account_changed_by is null and old.account_changed_by is not null
and new.account_status is not distinct from old.account_status
and new.account_reason is not distinct from old.account_reason
and new.account_changed_at is not distinct from old.account_changed_at
and new.account_status_version is not distinct from old.account_status_version then return new; end if;
if current_user not in ('postgres','service_role') and (
(tg_op='INSERT' and (new.account_status<>'active' or new.account_reason is not null or new.account_changed_at is not null or new.account_changed_by is not null or new.account_status_version<>0))
or (tg_op='UPDATE' and (new.account_status is distinct from old.account_status or new.account_reason is distinct from old.account_reason
or new.account_changed_at is distinct from old.account_changed_at or new.account_changed_by is distinct from old.account_changed_by
or new.account_status_version is distinct from old.account_status_version))
) then raise exception using errcode='42501',message='Account status is managed by Admins'; end if;
return new;
end; $$;
create trigger profiles_protect_account_status before insert or update on public.profiles for each row execute function public.protect_account_status();
revoke all on function public.protect_account_status() from public,anon,authenticated;
create table public.account_appeals (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles(id) on delete cascade,
status_version integer not null check(status_version>0),
reason text not null check(length(btrim(reason)) between 10 and 2000),
state text not null default 'open' check(state in ('open','approved','rejected')),
created_at timestamptz not null default clock_timestamp(),
reviewer_id uuid references public.profiles(id) on delete set null,
reviewed_at timestamptz, review_reason text,
unique(owner_id,status_version)
);
create index account_appeals_queue on public.account_appeals(created_at,id) where state='open';
create table public.account_control_events (
id uuid primary key default gen_random_uuid(),
owner_id uuid references public.profiles(id) on delete set null,
reviewer_id uuid references public.profiles(id) on delete set null,
action text not null check(action in ('deactivate','ban','restore','prepare_delete','appeal_approved','appeal_rejected')),
previous_status text not null, new_status text not null,
status_version integer not null,
reason text not null check(length(btrim(reason)) between 10 and 2000),
created_at timestamptz not null default clock_timestamp()
);
create index account_control_events_owner on public.account_control_events(owner_id,created_at desc);
alter table public.account_appeals enable row level security;
alter table public.account_control_events enable row level security;
revoke all on public.account_appeals,public.account_control_events from public,anon,authenticated;
grant select,insert,update,delete on public.account_appeals,public.account_control_events to service_role;
create policy "Server manages account appeals" on public.account_appeals for all to service_role using(true) with check(true);
create policy "Server manages account events" on public.account_control_events for all to service_role using(true) with check(true);
-- Retain shared maps and moderation history when the Auth account is deleted.
alter table public.datasets alter column owner_id drop not null;
alter table public.datasets drop constraint datasets_owner_id_fkey;
alter table public.datasets add constraint datasets_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete set null;
alter table public.review_decisions alter column reviewer_id drop not null;
alter table public.review_decisions drop constraint review_decisions_reviewer_id_fkey;
alter table public.review_decisions add constraint review_decisions_reviewer_id_fkey foreign key(reviewer_id) references auth.users(id) on delete set null;
alter table public.reports drop constraint reports_owner_id_fkey;
alter table public.reports add constraint reports_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete set null;
alter table public.reports drop constraint reports_reviewer_id_fkey;
alter table public.reports add constraint reports_reviewer_id_fkey foreign key(reviewer_id) references public.profiles(id) on delete set null;
alter table public.map_appeals alter column owner_id drop not null;
alter table public.map_appeals drop constraint map_appeals_owner_id_fkey;
alter table public.map_appeals add constraint map_appeals_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete set null;
alter table public.map_appeals drop constraint map_appeals_reviewer_id_fkey;
alter table public.map_appeals add constraint map_appeals_reviewer_id_fkey foreign key(reviewer_id) references public.profiles(id) on delete set null;
alter table public.pairing_codes drop constraint pairing_codes_owner_id_fkey;
alter table public.pairing_codes add constraint pairing_codes_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete cascade;
-- The same owner lock is used by API key creation/consumption and account actions.
create function public.admin_account_action(p_reviewer uuid,p_user uuid,p_action text,p_reason text) returns jsonb
language plpgsql security invoker set search_path='' as $$
declare target public.profiles; next_status text;
begin
if p_action is null or p_action not in ('deactivate','ban','restore','prepare_delete')
or coalesce(length(btrim(p_reason)),0) not between 10 and 2000 then
raise exception using errcode='22023',message='Provide an account action and reason in 10–2000 characters'; end if;
if p_reviewer=p_user then raise exception using errcode='42501',message='You cannot restrict your own account'; end if;
-- Deterministic profile lock order also prevents concurrent Admin actions deadlocking.
perform id from public.profiles where id in(p_reviewer,p_user) order by id for update;
if not exists(select 1 from public.profiles where id=p_reviewer and role='admin' and account_status='active') then
raise exception using errcode='42501',message='Active Admin permission required'; end if;
select * into target from public.profiles where id=p_user;
if not found then raise exception using errcode='P0002',message='Account not found'; end if;
if target.role='admin' then raise exception using errcode='42501',message='Admin accounts are protected'; end if;
if target.account_status='deleting' and p_action<>'prepare_delete' then
raise exception using errcode='22023',message='Permanent deletion is pending; retry deletion'; end if;
next_status:=case p_action when 'deactivate' then 'deactivated' when 'ban' then 'banned' when 'restore' then 'active' else 'deleting' end;
if target.account_status=next_status then
if next_status='deleting' then return jsonb_build_object('id',target.id,'status',target.account_status,'statusVersion',target.account_status_version); end if;
raise exception using errcode='22023',message='Account already has this status'; end if;
update public.profiles set account_status=next_status,account_reason=case when next_status='active' then null else btrim(p_reason) end,
account_changed_at=clock_timestamp(),account_changed_by=p_reviewer,account_status_version=account_status_version+1 where id=p_user;
update public.account_appeals set state='rejected',reviewer_id=p_reviewer,reviewed_at=clock_timestamp(),
review_reason='Closed because the account status changed.' where owner_id=p_user and state='open';
if next_status<>'active' then
update public.contributor_tokens set revoked=true where owner_id=p_user and not revoked;
update public.developer_api_keys set revoked_at=coalesce(revoked_at,clock_timestamp()) where owner_id=p_user;
delete from public.pairing_codes where owner_id=p_user;
-- Secret-cookie capabilities must not survive a restriction or a later restore.
update public.game_sessions set access_hash=replace(gen_random_uuid()::text,'-','')||replace(gen_random_uuid()::text,'-','') where owner_id=p_user;
-- Close hosted rooms first, so Discord closure hooks run normally.
update public.multiplayer_rooms set status='closed',transition_at=null where status<>'closed'
and host_player_id in(select id from public.multiplayer_players where account_id=p_user);
update public.multiplayer_players set has_left=true,access_hash=replace(gen_random_uuid()::text,'-','')||replace(gen_random_uuid()::text,'-','') where account_id=p_user;
end if;
insert into public.account_control_events(owner_id,reviewer_id,action,previous_status,new_status,status_version,reason)
values(p_user,p_reviewer,p_action,target.account_status,next_status,target.account_status_version+1,btrim(p_reason));
return jsonb_build_object('id',p_user,'status',next_status,'statusVersion',target.account_status_version+1);
end; $$;
create function public.submit_account_appeal(p_owner uuid,p_reason text) returns uuid
language plpgsql security invoker set search_path='' as $$
declare target public.profiles; appeal uuid;
begin
select * into target from public.profiles where id=p_owner for update;
if not found or target.account_status<>'banned' then raise exception using errcode='42501',message='Only banned accounts may appeal'; end if;
if coalesce(length(btrim(p_reason)),0) not between 10 and 2000 then raise exception using errcode='22023',message='Explain your appeal in 10–2000 characters'; end if;
if exists(select 1 from public.account_appeals where owner_id=p_owner and status_version=target.account_status_version) then
raise exception using errcode='22023',message='An appeal has already been submitted for this ban'; end if;
insert into public.account_appeals(owner_id,status_version,reason) values(p_owner,target.account_status_version,btrim(p_reason)) returning id into appeal;
return appeal;
end; $$;
create function public.review_account_appeal(p_reviewer uuid,p_appeal uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare appeal public.account_appeals; target public.profiles; owner uuid;
begin
select owner_id into owner from public.account_appeals where id=p_appeal;
-- Profile before appeal: same order as status actions and appeal submission.
perform id from public.profiles where id in(p_reviewer,owner) order by id for update;
if not exists(select 1 from public.profiles where id=p_reviewer and role in('moderator','admin') and account_status='active') then
raise exception using errcode='42501',message='Active Moderator permission required'; end if;
if p_reviewer=owner then raise exception using errcode='42501',message='An independent moderator must review your appeal'; end if;
select * into target from public.profiles where id=owner;
select * into appeal from public.account_appeals where id=p_appeal for update;
if not found or appeal.state<>'open' or target.account_status<>'banned' or target.account_status_version<>appeal.status_version then
raise exception using errcode='22023',message='Appeal is no longer awaiting review'; end if;
if p_decision is null or p_decision not in('approve','reject') or coalesce(length(btrim(p_reason)),0) not between 10 and 2000 then
raise exception using errcode='22023',message='Provide an appeal decision and reason in 10–2000 characters'; end if;
if p_decision='approve' then
update public.profiles set account_status='active',account_reason=null,account_changed_at=clock_timestamp(),
account_changed_by=p_reviewer,account_status_version=account_status_version+1 where id=owner;
end if;
update public.account_appeals set state=case when p_decision='approve' then 'approved' else 'rejected' end,
reviewer_id=p_reviewer,reviewed_at=clock_timestamp(),review_reason=btrim(p_reason) where id=p_appeal;
insert into public.account_control_events(owner_id,reviewer_id,action,previous_status,new_status,status_version,reason)
values(owner,p_reviewer,case when p_decision='approve' then 'appeal_approved' else 'appeal_rejected' end,'banned',
case when p_decision='approve' then 'active' else 'banned' end,target.account_status_version+case when p_decision='approve' then 1 else 0 end,btrim(p_reason));
end; $$;
revoke all on function public.admin_account_action(uuid,uuid,text,text),public.submit_account_appeal(uuid,text),public.review_account_appeal(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.admin_account_action(uuid,uuid,text,text),public.submit_account_appeal(uuid,text),public.review_account_appeal(uuid,uuid,text,text) to service_role;
-- Harden existing server-only functions without duplicating their validated bodies.
do $$ declare signature text; body text; begin
for signature in select unnest(array['public.developer_key_create(uuid,text,text[],text,text,integer)','public.developer_key_consume(text)']) loop
body:=pg_get_functiondef(signature::regprocedure);
body:=replace(body,'where p.id=p_owner for update','where p.id=p_owner and p.account_status=''active'' for update');
body:=replace(body,'where p.id=owner for update','where p.id=owner and p.account_status=''active'' for update');
execute body;
end loop;
for signature in select unnest(array['public.prometheus_status(uuid)','public.configure_prometheus(uuid,text)','public.read_prometheus_metrics(text)']) loop
body:=pg_get_functiondef(signature::regprocedure);
body:=replace(body,'where id=p_reviewer and role=''admin''','where id=p_reviewer and role=''admin'' and account_status=''active''');
body:=replace(body,'p.role=''admin''','p.role=''admin'' and p.account_status=''active''');
execute body;
end loop;
end; $$;
-- A stale browser JWT cannot use direct Data API reads for private content/jobs.
drop policy datasets_visible on public.datasets;
create policy datasets_visible on public.datasets for select to anon,authenticated using(
state='published' or (owner_id=(select auth.uid()) and exists(select 1 from public.profiles p where p.id=(select auth.uid()) and p.account_status='active')));
drop policy own_jobs on public.upload_jobs;
create policy own_jobs on public.upload_jobs for select to authenticated using(
exists(select 1 from public.datasets where id=dataset_id and owner_id=(select auth.uid()))
and exists(select 1 from public.profiles p where p.id=(select auth.uid()) and p.account_status='active'));

View File

@@ -0,0 +1,100 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
accountActionInput,
accountAppealInput,
accountAppealReviewInput,
restrictionPageAllowed,
} from "../shared/account-controls";
test("account controls reject forged identity, role, status and unknown actions", () => {
const reason = "A detailed reason for restricting this account.";
for (const action of ["deactivate", "ban", "restore", "delete"])
assert.equal(
accountActionInput.safeParse({ action, reason }).success,
true,
);
for (const extra of [
{ owner_id: "someone" },
{ role: "admin" },
{ status: "active" },
])
assert.equal(
accountActionInput.safeParse({ action: "ban", reason, ...extra }).success,
false,
);
for (const action of ["promote", "prepare_delete", "approve"])
assert.equal(
accountActionInput.safeParse({ action, reason }).success,
false,
);
for (const reason of ["short", " ".repeat(20), "x".repeat(2001)])
assert.equal(
accountActionInput.safeParse({ action: "ban", reason }).success,
false,
);
assert.equal(
accountActionInput.safeParse({
action: "delete",
reason,
confirmation: "wrong-id",
}).success,
false,
);
});
test("appeals cannot choose their owner, ban version, reviewer or outcome", () => {
const reason = "Please review the circumstances of this ban.";
assert.equal(
accountAppealInput.parse({ reason: " " + reason + " " }).reason,
reason,
);
for (const extra of [
{ owner_id: "other" },
{ status_version: 99 },
{ state: "approved" },
])
assert.equal(
accountAppealInput.safeParse({ reason, ...extra }).success,
false,
);
assert.equal(
accountAppealReviewInput.safeParse({ reason, decision: "approve" }).success,
true,
);
assert.equal(
accountAppealReviewInput.safeParse({ reason, decision: "reject" }).success,
true,
);
assert.equal(
accountAppealReviewInput.safeParse({
reason,
decision: "restore",
reviewer_id: "admin",
}).success,
false,
);
});
test("restricted page allowlist cannot admit game, dashboard or staff pages via prefixes", () => {
for (const path of [
"/ban/appeal",
"/docs",
"/log-in",
"/auth/callback",
"/auth/reset-password",
])
assert.equal(restrictionPageAllowed(path), true, path);
for (const path of [
"/",
"/moderation",
"/dashboard",
"/servers",
"/dev/api",
"/ban/appeal/other",
"/docs/../moderation",
"/auth/admin",
"/auth/callback/other",
])
assert.equal(restrictionPageAllowed(path), false, path);
});