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

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