282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
"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";
|
||
import { useAppealRealtime } from "./useAppealRealtime";
|
||
|
||
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);
|
||
const statusReads = useRef(0);
|
||
const realtime = useAppealRealtime({
|
||
enabled: Boolean(data?.account.id),
|
||
ownerId: data?.account.id,
|
||
refresh: async () => {
|
||
const version = ++statusReads.current;
|
||
const result = await api<{
|
||
account: ManagedAccount;
|
||
appeals: AccountAppealRecord[];
|
||
}>("ban/appeal");
|
||
if (version === statusReads.current) setData(result);
|
||
},
|
||
onError: (error) => setError((error as Error).message),
|
||
});
|
||
useEffect(() => {
|
||
let active = true;
|
||
const version = ++statusReads.current;
|
||
api<{ account: ManagedAccount; appeals: AccountAppealRecord[] }>(
|
||
"ban/appeal",
|
||
)
|
||
.then((result) => {
|
||
if (active && version === statusReads.current) 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() {
|
||
const version = ++statusReads.current;
|
||
const result = await api<{
|
||
account: ManagedAccount;
|
||
appeals: AccountAppealRecord[];
|
||
}>("ban/appeal");
|
||
if (version === statusReads.current) {
|
||
setData(result);
|
||
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 && (
|
||
<p className="muted" style={{ marginTop: 20 }}>
|
||
{realtime === "live"
|
||
? l(
|
||
"Live updates connected",
|
||
"Обновления в реальном времени подключены",
|
||
)
|
||
: realtime === "offline"
|
||
? l(
|
||
"Reconnecting… Refresh remains available.",
|
||
"Переподключение… Можно обновить вручную.",
|
||
)
|
||
: l("Connecting live updates…", "Подключение обновлений…")}
|
||
</p>
|
||
)}
|
||
{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 your status in Settings, use 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>
|
||
);
|
||
}
|