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