Files
MineGeo/components/AccountAppeal.tsx

282 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}