Files
MineGeo/components/AccountControls.tsx

419 lines
16 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 { 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>
)}
</>
);
}