Add live account appeal updates and restricted status settings
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
/* Shared account and authentication surfaces, layered after the Minecraft theme. */
|
||||
.account-status-card {
|
||||
margin-bottom: 32px;
|
||||
gap: 18px;
|
||||
}
|
||||
.account-status-card h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.account-status-reason {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.auth-page {
|
||||
padding-top: 48px;
|
||||
padding-bottom: 72px;
|
||||
@@ -284,10 +295,34 @@
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
.legacy-password-dialog { width: min(540px, calc(100vw - 24px)); }
|
||||
.legacy-password-dialog > svg { color: var(--green); }
|
||||
.legacy-password-dialog h2 { margin-top: 16px; }
|
||||
.legacy-password-dialog .field input { width: 100%; font: 14px monospace; padding: 12px; background: var(--bg); color: var(--text); border: 1px solid var(--line); }
|
||||
.legacy-password-dialog > .text-link { margin-top: 18px; }
|
||||
.password-saved { display: flex; gap: 10px; align-items: flex-start; margin: 20px 0; }
|
||||
.password-saved input { flex-shrink: 0; margin-top: 5px; accent-color: var(--green); }
|
||||
.legacy-password-dialog {
|
||||
width: min(540px, calc(100vw - 24px));
|
||||
}
|
||||
.legacy-password-dialog > svg {
|
||||
color: var(--green);
|
||||
}
|
||||
.legacy-password-dialog h2 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.legacy-password-dialog .field input {
|
||||
width: 100%;
|
||||
font: 14px monospace;
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.legacy-password-dialog > .text-link {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.password-saved {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.password-saved input {
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
accent-color: var(--green);
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export default async function AdminPage() {
|
||||
"Manage accounts, site settings, integrations and community reports.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<div className="staff-page-switch">
|
||||
<Link className="button" href="/moderation">
|
||||
{t("Open Moderation Tools")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Moderation mode="admin" />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -25,8 +25,15 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.refreshActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 24px 0 20px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
|
||||
@@ -17,11 +17,11 @@ export default async function Review() {
|
||||
{t("Review pending maps, map updates and account ban appeals.")}
|
||||
</p>
|
||||
{isAdministrator(staff.role) && (
|
||||
<p>
|
||||
<div className="staff-page-switch">
|
||||
<Link className="button" href="/admin">
|
||||
{t("Open Admin Tools")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Moderation />
|
||||
</main>
|
||||
|
||||
@@ -245,6 +245,12 @@ html[lang="ru"] .mode-row h3 span {
|
||||
.moderation-page :is(.tool-card, .draft-row) {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.staff-page-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin: 24px 0 32px;
|
||||
}
|
||||
.moderator-setting-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
@@ -3,9 +3,10 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { AccountSettings } from "@/components/AccountSettings";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { rawUser } from "@/lib/supabase";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
import { Username } from "@/components/Username";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
export async function generateMetadata() {
|
||||
const { t } = await getI18n();
|
||||
return { title: t("Account settings") };
|
||||
@@ -16,18 +17,30 @@ export default async function Settings({
|
||||
searchParams: Promise<{ password?: string }>;
|
||||
}) {
|
||||
const { t } = await getI18n();
|
||||
const account = await user();
|
||||
const account = await rawUser();
|
||||
if (!account) redirect("/log-in?next=%2Fsettings");
|
||||
const [profile, query] = await Promise.all([
|
||||
accountProfile(account),
|
||||
searchParams,
|
||||
]);
|
||||
const active = profile.account_status === "active";
|
||||
const statusLabel =
|
||||
profile.account_status === "banned"
|
||||
? "Banned"
|
||||
: profile.account_status === "deactivated"
|
||||
? "Deactivated"
|
||||
: profile.account_status === "deleting"
|
||||
? "Deletion pending"
|
||||
: "Active";
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<Link className="text-link" href="/dashboard">
|
||||
{t("← My account")}
|
||||
<Link
|
||||
className="text-link"
|
||||
href={active ? "/dashboard" : "/ban/appeal"}
|
||||
>
|
||||
{t(active ? "← My account" : "← Account access & appeals")}
|
||||
</Link>
|
||||
<div className="account-page-heading">
|
||||
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
||||
@@ -37,12 +50,44 @@ export default async function Settings({
|
||||
{t("Your name, your sign-in, your next expedition.")}
|
||||
</p>
|
||||
</div>
|
||||
<AccountSettings
|
||||
email={account.email ?? ""}
|
||||
displayName={profile.display_name}
|
||||
appearance={profile}
|
||||
passwordUpdated={query.password === "updated"}
|
||||
/>
|
||||
<section
|
||||
className="tool-card account-status-card"
|
||||
aria-labelledby="account-status-title"
|
||||
>
|
||||
<ShieldCheck size={24} aria-hidden="true" />
|
||||
<h2 id="account-status-title">{t("Account status")}</h2>
|
||||
<p>
|
||||
<span
|
||||
className="badge"
|
||||
data-account-status={profile.account_status}
|
||||
>
|
||||
{t(statusLabel)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
active
|
||||
? "Your account is active. You can use MineGeo normally."
|
||||
: "Account changes are disabled while access is restricted.",
|
||||
)}
|
||||
</p>
|
||||
{!active && profile.account_reason && (
|
||||
<p className="account-status-reason">{profile.account_reason}</p>
|
||||
)}
|
||||
{!active && (
|
||||
<Link className="button" href="/ban/appeal">
|
||||
{t("View account status & appeal")}
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
{active && (
|
||||
<AccountSettings
|
||||
email={account.email ?? ""}
|
||||
displayName={profile.display_name}
|
||||
appearance={profile}
|
||||
passwordUpdated={query.password === "updated"}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AccountAppealRecord,
|
||||
} from "@/shared/account-controls";
|
||||
import styles from "@/app/ban/appeal/page.module.css";
|
||||
import { useAppealRealtime } from "./useAppealRealtime";
|
||||
|
||||
export function AccountAppeal() {
|
||||
const { locale } = useI18n();
|
||||
@@ -22,13 +23,28 @@ export function AccountAppeal() {
|
||||
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) setData(result);
|
||||
if (active && version === statusReads.current) setData(result);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError((e as Error).message);
|
||||
@@ -55,8 +71,15 @@ export function AccountAppeal() {
|
||||
}
|
||||
}
|
||||
async function reload() {
|
||||
setData(await api("ban/appeal"));
|
||||
setLoading(false);
|
||||
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,
|
||||
@@ -107,6 +130,21 @@ export function AccountAppeal() {
|
||||
{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 }}>
|
||||
@@ -138,8 +176,8 @@ export function AccountAppeal() {
|
||||
) : (
|
||||
<p>
|
||||
{l(
|
||||
"Normal account access, uploads and API keys are blocked. You can still read the docs, view this page and sign out.",
|
||||
"Обычный доступ к аккаунту, загрузки и ключи API заблокированы. Вы можете читать документацию, открывать эту страницу и выходить из аккаунта.",
|
||||
"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>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AccountAppealRecord,
|
||||
} from "@/shared/account-controls";
|
||||
import styles from "@/app/ban/appeal/page.module.css";
|
||||
import { useAppealRealtime } from "./useAppealRealtime";
|
||||
|
||||
export function AccountControls({
|
||||
administrator,
|
||||
@@ -28,6 +29,7 @@ export function AccountControls({
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const lock = useRef(false);
|
||||
const appealReads = useRef(0);
|
||||
const [selected, setSelected] = useState<{
|
||||
account: ManagedAccount;
|
||||
action: "deactivate" | "ban" | "restore" | "delete";
|
||||
@@ -37,19 +39,38 @@ export function AccountControls({
|
||||
const [reviewReasons, setReviewReasons] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const realtime = useAppealRealtime({
|
||||
enabled: showAppeals,
|
||||
refresh: async () => {
|
||||
const version = ++appealReads.current;
|
||||
const result = await api<{ appeals: AccountAppealRecord[] }>(
|
||||
"moderation/account-appeals",
|
||||
);
|
||||
if (version === appealReads.current) {
|
||||
setAppeals(result.appeals);
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setError((error as Error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAppeals) return;
|
||||
let active = true;
|
||||
const version = ++appealReads.current;
|
||||
api<{ appeals: AccountAppealRecord[] }>("moderation/account-appeals")
|
||||
.then((data) => {
|
||||
if (active) setAppeals(data.appeals);
|
||||
if (active && version === appealReads.current) setAppeals(data.appeals);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError((e as Error).message);
|
||||
if (active && version === appealReads.current)
|
||||
setError((e as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
if (active && version === appealReads.current) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -81,16 +102,14 @@ export function AccountControls({
|
||||
async function refreshAppeals() {
|
||||
if (!showAppeals) return;
|
||||
setLoading(true);
|
||||
const version = ++appealReads.current;
|
||||
try {
|
||||
setAppeals(
|
||||
(
|
||||
await api<{ appeals: AccountAppealRecord[] }>(
|
||||
"moderation/account-appeals",
|
||||
)
|
||||
).appeals,
|
||||
const result = await api<{ appeals: AccountAppealRecord[] }>(
|
||||
"moderation/account-appeals",
|
||||
);
|
||||
if (version === appealReads.current) setAppeals(result.appeals);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (version === appealReads.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
async function save(event: React.FormEvent) {
|
||||
@@ -320,14 +339,29 @@ export function AccountControls({
|
||||
"Модераторы и администраторы могут рассматривать апелляции. Одобрение восстанавливает доступ после этой блокировки. Деактивация и удаление доступны только администраторам.",
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void run(refreshAppeals)}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Refresh appeals", "Обновить апелляции")}
|
||||
</button>
|
||||
<div className={styles.refreshActions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void run(refreshAppeals)}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Refresh appeals", "Обновить апелляции")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{realtime === "live"
|
||||
? l(
|
||||
"Live updates connected",
|
||||
"Обновления в реальном времени подключены",
|
||||
)
|
||||
: realtime === "offline"
|
||||
? l(
|
||||
"Reconnecting… Refresh remains available.",
|
||||
"Переподключение… Можно обновить вручную.",
|
||||
)
|
||||
: l("Connecting live updates…", "Подключение обновлений…")}
|
||||
</span>
|
||||
</div>
|
||||
{loading && (
|
||||
<p role="status">{l("Loading appeals…", "Загрузка апелляций…")}</p>
|
||||
)}
|
||||
|
||||
128
components/useAppealRealtime.ts
Normal file
128
components/useAppealRealtime.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { realtimeRefresh } from "@/lib/realtime-refresh";
|
||||
|
||||
export function useAppealRealtime({
|
||||
enabled,
|
||||
ownerId,
|
||||
refresh,
|
||||
onError,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
ownerId?: string;
|
||||
refresh: () => Promise<void>;
|
||||
onError: (error: unknown) => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<"connecting" | "live" | "offline">(
|
||||
"connecting",
|
||||
);
|
||||
const handlers = useRef({ refresh, onError });
|
||||
useEffect(() => {
|
||||
handlers.current = { refresh, onError };
|
||||
}, [refresh, onError]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
||||
if (!url || !key) {
|
||||
setStatus("offline");
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let connected = false;
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
const updates = realtimeRefresh(
|
||||
() => handlers.current.refresh(),
|
||||
(error) => handlers.current.onError(error),
|
||||
);
|
||||
const invalidate = () => {
|
||||
if (!active || debounce !== undefined) return;
|
||||
debounce = setTimeout(() => {
|
||||
debounce = undefined;
|
||||
void updates.request();
|
||||
}, 200);
|
||||
};
|
||||
const client = createBrowserClient(url, key);
|
||||
const options = {
|
||||
schema: "public",
|
||||
table: "account_appeals",
|
||||
...(ownerId ? { filter: `owner_id=eq.${ownerId}` } : {}),
|
||||
};
|
||||
const channel = client
|
||||
.channel(`account-appeals:${ownerId ?? "staff"}:${crypto.randomUUID()}`, {
|
||||
config: { postgres_changes_options: { wait: true } },
|
||||
})
|
||||
.on("system", {}, (event) => {
|
||||
if (
|
||||
active &&
|
||||
event.extension === "postgres_changes" &&
|
||||
event.status === "error"
|
||||
) {
|
||||
connected = false;
|
||||
setStatus("offline");
|
||||
console.warn(
|
||||
"Appeal live updates unavailable:",
|
||||
String(event.message ?? "subscription error")
|
||||
.replace(/eyJ[A-Za-z0-9_.-]+/g, "[redacted]")
|
||||
.slice(0, 240),
|
||||
);
|
||||
}
|
||||
})
|
||||
// DELETE events cannot be checked against a surviving row's RLS policy.
|
||||
.on("postgres_changes", { ...options, event: "INSERT" }, invalidate)
|
||||
.on("postgres_changes", { ...options, event: "UPDATE" }, invalidate);
|
||||
if (ownerId)
|
||||
channel.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "UPDATE",
|
||||
schema: "public",
|
||||
table: "profiles",
|
||||
filter: `id=eq.${ownerId}`,
|
||||
},
|
||||
invalidate,
|
||||
);
|
||||
channel.subscribe((state, error) => {
|
||||
if (!active) return;
|
||||
connected = state === "SUBSCRIBED";
|
||||
if (error)
|
||||
console.warn(
|
||||
"Appeal live updates unavailable:",
|
||||
error.message
|
||||
.replace(/eyJ[A-Za-z0-9_.-]+/g, "[redacted]")
|
||||
.slice(0, 240),
|
||||
);
|
||||
setStatus(
|
||||
connected
|
||||
? "live"
|
||||
: state === "CLOSED" ||
|
||||
state === "CHANNEL_ERROR" ||
|
||||
state === "TIMED_OUT"
|
||||
? "offline"
|
||||
: "connecting",
|
||||
);
|
||||
if (connected) invalidate(); // Close the initial-load/reconnection gap.
|
||||
});
|
||||
const visible = () => {
|
||||
if (document.visibilityState === "visible") invalidate();
|
||||
};
|
||||
document.addEventListener("visibilitychange", visible);
|
||||
window.addEventListener("focus", invalidate);
|
||||
window.addEventListener("online", invalidate);
|
||||
const fallback = setInterval(() => {
|
||||
if (!connected && document.visibilityState === "visible") invalidate();
|
||||
}, 30000);
|
||||
return () => {
|
||||
active = false;
|
||||
updates.close();
|
||||
if (debounce !== undefined) clearTimeout(debounce);
|
||||
clearInterval(fallback);
|
||||
document.removeEventListener("visibilitychange", visible);
|
||||
window.removeEventListener("focus", invalidate);
|
||||
window.removeEventListener("online", invalidate);
|
||||
void client.removeChannel(channel);
|
||||
};
|
||||
}, [enabled, ownerId]);
|
||||
return status;
|
||||
}
|
||||
@@ -8,14 +8,14 @@ If your account is restricted, sign in as usual. MineGeo will take you to **Acco
|
||||
- **Banned:** normal account access is blocked. You can submit an appeal explaining why the decision should be reconsidered.
|
||||
- **Deletion pending:** an Admin has started removing the account. Access remains blocked while the action completes. Only an Admin can finish it.
|
||||
|
||||
While restricted, you can read the docs, check this page, sign out, and use the sign-in or password recovery flow. You cannot play through that account, upload maps, use its API keys, or use staff tools. Existing upload and API keys are revoked and existing games and multiplayer participation are ended. Restoring access does not reactivate old keys; create new ones when needed.
|
||||
Settings shows your account status. While restricted, Settings is read-only: you can view the reason and open your appeal, but normal account changes stay blocked. You can also read the docs, check the appeal page, sign out, and use the sign-in or password recovery flow. You cannot play through that account, upload maps, use its API keys, or use staff tools. Existing upload and API keys are revoked and existing games and multiplayer participation are ended. Restoring access does not reactivate old keys; create new ones when needed.
|
||||
|
||||
## Send an appeal
|
||||
|
||||
1. Sign in to the banned account and open **/ban/appeal**.
|
||||
2. Read the ban reason. Explain what happened and why the ban should be reconsidered in **10–2000 characters**.
|
||||
3. Click **Send appeal**. You can send **one appeal for each ban**, so include the useful details before sending.
|
||||
4. Return to this page and click **Refresh status** to see the decision.
|
||||
4. Keep this page open: appeal decisions and account status update automatically while connected. **Refresh status** is available if the connection drops.
|
||||
|
||||
Both Moderators and Admins can review account ban appeals. An approval restores access for the ban being reviewed. A rejection keeps the ban. Staff leave a reason you can read on the same page. You cannot submit the same ban again after a rejection; contact the team if you have new information.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Deploy MineGeo
|
||||
|
||||
Account restrictions and appeals require `20261013070138_account_controls_and_appeals.sql` before deploying.
|
||||
Live appeal updates additionally require `20261013074208_account_appeals_realtime.sql`,
|
||||
which adds restricted read policies and the Realtime publication tables. Existing
|
||||
Supabase public environment variables are sufficient; no new secrets are needed.
|
||||
No new environment variables are needed. Admin Tools is at `/admin` (Admin only), Moderation Tools is at `/moderation`
|
||||
(Moderators and Admins); account status and appeals are at `/ban/appeal`. The built-in
|
||||
guide is `/docs?page=account-bans`. See [Account controls](account-controls.md).
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function accountProfile(account: User) {
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
|
||||
)
|
||||
.eq("id", account.id)
|
||||
.single(),
|
||||
|
||||
34
lib/realtime-refresh.ts
Normal file
34
lib/realtime-refresh.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** Serialize refreshes, retaining one follow-up if a change arrives mid-request. */
|
||||
export function realtimeRefresh(
|
||||
refresh: () => Promise<void>,
|
||||
onError: (error: unknown) => void,
|
||||
) {
|
||||
let running = false;
|
||||
let pending = false;
|
||||
let closed = false;
|
||||
async function request() {
|
||||
if (closed) return;
|
||||
pending = true;
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
while (pending && !closed) {
|
||||
pending = false;
|
||||
try {
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (!closed) onError(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
request,
|
||||
close: () => {
|
||||
closed = true;
|
||||
pending = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,9 @@ try {
|
||||
// Neither anonymous callers nor logged-in browser roles get server-only powers.
|
||||
for(const role of ["anon","authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for(const sql of ["select * from public.account_appeals","select * from public.account_control_events",
|
||||
if(role==="anon") await assert.rejects(db.query("select * from public.account_appeals"),/permission denied/);
|
||||
else assert.equal((await db.query("select * from public.account_appeals")).rows.length,0);
|
||||
for(const sql of ["select * from public.account_control_events",
|
||||
`select public.admin_account_action('${admin}','${owner}','ban','${reason}')`,
|
||||
`select public.submit_account_appeal('${owner}','${reason}')`,
|
||||
`select public.review_account_appeal('${admin}','${id(99)}','approve','${reason}')`])
|
||||
|
||||
38
scripts/test-appeal-realtime-sql.mjs
Normal file
38
scripts/test-appeal-realtime-sql.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFile,readdir} from "node:fs/promises";
|
||||
import {PGlite} from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
const db=new PGlite();
|
||||
const id=n=>`00000000-0000-4000-8000-${String(n).padStart(12,"0")}`;
|
||||
const [owner,other,player,moderator,admin]=[1,2,3,4,5].map(id);
|
||||
try {
|
||||
await db.exec(`create role anon;create role authenticated;create role service_role bypassrls;
|
||||
create schema auth;create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$select nullif(current_setting('request.jwt.claim.sub',true),'')::uuid$$;
|
||||
create schema storage;create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);
|
||||
create publication supabase_realtime;`);
|
||||
for(const file of (await readdir("supabase/migrations")).filter(f=>f.endsWith(".sql")).sort()) await db.exec(await readFile(`supabase/migrations/${file}`,"utf8"));
|
||||
for(const user of [owner,other,player,moderator,admin]) await db.query("insert into auth.users(id) values($1)",[user]);
|
||||
await db.query("insert into public.profiles(id,role) values($1,'contributor'),($2,'contributor'),($3,'contributor'),($4,'moderator'),($5,'admin')",[owner,other,player,moderator,admin]);
|
||||
await db.query("update public.profiles set account_status='banned',account_status_version=1 where id in($1,$2)",[owner,other]);
|
||||
await db.query("insert into public.account_appeals(owner_id,status_version,reason) values($1,1,'First private appeal reason'),($2,1,'Second private appeal reason')",[owner,other]);
|
||||
const visible=async(user)=>{
|
||||
await db.query("select set_config('request.jwt.claim.sub',$1,false)",[user]);
|
||||
await db.exec("set role authenticated");
|
||||
try{return (await db.query("select owner_id from public.account_appeals order by owner_id")).rows.map(r=>r.owner_id);}
|
||||
finally{await db.exec("reset role");}
|
||||
};
|
||||
assert.deepEqual(await visible(owner),[owner],"Banned players can receive only their own history");
|
||||
assert.deepEqual(await visible(other),[other]);
|
||||
assert.deepEqual(await visible(player),[],"Ordinary players cannot observe the staff queue");
|
||||
assert.deepEqual(await visible(moderator),[owner,other]);
|
||||
assert.deepEqual(await visible(admin),[owner,other]);
|
||||
await db.query("update public.profiles set account_status='banned' where id=$1",[moderator]);
|
||||
assert.deepEqual(await visible(moderator),[],"A stale Moderator JWT cannot retain staff access");
|
||||
await db.query("select set_config('request.jwt.claim.sub',$1,false)",[admin]);await db.exec("set role authenticated");
|
||||
for(const sql of ["update public.account_appeals set state='approved'",`insert into public.account_appeals(owner_id,status_version,reason) values('${owner}',2,'Forged direct write is rejected')`,"delete from public.account_appeals"])
|
||||
await assert.rejects(db.query(sql),/permission denied/);
|
||||
await db.exec("reset role;set role anon");await assert.rejects(db.query("select * from public.account_appeals"),/permission denied/);await db.exec("reset role");
|
||||
const published=(await db.query("select tablename from pg_publication_tables where pubname='supabase_realtime' order by tablename")).rows.map(r=>r.tablename);
|
||||
assert.deepEqual(published,["account_appeals","profiles"]);
|
||||
console.log("Appeal Realtime SQL checks passed: own history, active staff, revoked staff role/status, denied anonymous reads/direct writes, publication tables.");
|
||||
} finally {await db.close();}
|
||||
@@ -35,6 +35,7 @@ export function restrictionMessage(status: string) {
|
||||
export function restrictionPageAllowed(path: string) {
|
||||
return (
|
||||
path === "/ban/appeal" ||
|
||||
path === "/settings" ||
|
||||
path === "/docs" ||
|
||||
path === "/log-in" ||
|
||||
path === "/auth/callback" ||
|
||||
|
||||
@@ -896,5 +896,14 @@
|
||||
"Open Admin Tools": "Открыть инструменты администратора",
|
||||
"Open Moderation Tools": "Открыть инструменты модерации",
|
||||
"Review pending maps, map updates and account ban appeals.": "Рассматривайте новые карты, обновления карт и апелляции блокировок аккаунтов.",
|
||||
"Manage accounts, site settings, integrations and community reports.": "Управляйте аккаунтами, настройками сайта, интеграциями и жалобами сообщества."
|
||||
"Manage accounts, site settings, integrations and community reports.": "Управляйте аккаунтами, настройками сайта, интеграциями и жалобами сообщества.",
|
||||
"Account status": "Статус аккаунта",
|
||||
"Active": "Активен",
|
||||
"Banned": "Заблокирован",
|
||||
"Deactivated": "Деактивирован",
|
||||
"Deletion pending": "Ожидает удаления",
|
||||
"Your account is active. You can use MineGeo normally.": "Ваш аккаунт активен. Вы можете пользоваться MineGeo.",
|
||||
"Account changes are disabled while access is restricted.": "Изменения аккаунта недоступны, пока доступ ограничен.",
|
||||
"View account status & appeal": "Статус аккаунта и апелляция",
|
||||
"← Account access & appeals": "← Доступ к аккаунту и апелляции"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,6 @@
|
||||
{
|
||||
"slug": "account-bans",
|
||||
"title": "Account access and ban appeals",
|
||||
"markdown": "# Account access and ban appeals\n\nIf your account is restricted, sign in as usual. MineGeo will take you to **Account access & appeals**, where you can read the reason. You can also open **/ban/appeal** directly.\n\n## Deactivated or banned?\n\n- **Deactivated:** an Admin has temporarily disabled account access. An Admin must restore it; this does not use the ban appeal queue. Contact the team through the Discord community link in the footer.\n- **Banned:** normal account access is blocked. You can submit an appeal explaining why the decision should be reconsidered.\n- **Deletion pending:** an Admin has started removing the account. Access remains blocked while the action completes. Only an Admin can finish it.\n\nWhile restricted, you can read the docs, check this page, sign out, and use the sign-in or password recovery flow. You cannot play through that account, upload maps, use its API keys, or use staff tools. Existing upload and API keys are revoked and existing games and multiplayer participation are ended. Restoring access does not reactivate old keys; create new ones when needed.\n\n## Send an appeal\n\n1. Sign in to the banned account and open **/ban/appeal**.\n2. Read the ban reason. Explain what happened and why the ban should be reconsidered in **10–2000 characters**.\n3. Click **Send appeal**. You can send **one appeal for each ban**, so include the useful details before sending.\n4. Return to this page and click **Refresh status** to see the decision.\n\nBoth Moderators and Admins can review account ban appeals. An approval restores access for the ban being reviewed. A rejection keeps the ban. Staff leave a reason you can read on the same page. You cannot submit the same ban again after a rejection; contact the team if you have new information.\n\nIf an Admin changes the restriction while an appeal is waiting, the old appeal closes. An old approval cannot override a newer restriction.\n\n## Deleted accounts\n\nPermanent deletion removes sign-in, the profile, credentials and account appeals. It cannot be appealed or recovered through this page. Shared maps and community history can remain without an account owner; deletion does not erase the worlds other players use.\n\n## Staff controls\n\nOpen **Admin Tools** from the footer or go to **/admin**. This separate page is available only to Admins. Admins can search accounts, deactivate, ban, restore access, and permanently delete accounts. Every action needs a reason. Permanent deletion also requires typing the exact account ID. Admin accounts are protected from these controls.\n\nOpen **Moderation Tools** at **/moderation** to review maps and account ban appeals. Moderators and Admins can access this page. Moderators cannot deactivate, ban, delete, or directly restore arbitrary accounts."
|
||||
"markdown": "# Account access and ban appeals\n\nIf your account is restricted, sign in as usual. MineGeo will take you to **Account access & appeals**, where you can read the reason. You can also open **/ban/appeal** directly.\n\n## Deactivated or banned?\n\n- **Deactivated:** an Admin has temporarily disabled account access. An Admin must restore it; this does not use the ban appeal queue. Contact the team through the Discord community link in the footer.\n- **Banned:** normal account access is blocked. You can submit an appeal explaining why the decision should be reconsidered.\n- **Deletion pending:** an Admin has started removing the account. Access remains blocked while the action completes. Only an Admin can finish it.\n\nSettings shows your account status. While restricted, Settings is read-only: you can view the reason and open your appeal, but normal account changes stay blocked. You can also read the docs, check the appeal page, sign out, and use the sign-in or password recovery flow. You cannot play through that account, upload maps, use its API keys, or use staff tools. Existing upload and API keys are revoked and existing games and multiplayer participation are ended. Restoring access does not reactivate old keys; create new ones when needed.\n\n## Send an appeal\n\n1. Sign in to the banned account and open **/ban/appeal**.\n2. Read the ban reason. Explain what happened and why the ban should be reconsidered in **10–2000 characters**.\n3. Click **Send appeal**. You can send **one appeal for each ban**, so include the useful details before sending.\n4. Keep this page open: appeal decisions and account status update automatically while connected. **Refresh status** is available if the connection drops.\n\nBoth Moderators and Admins can review account ban appeals. An approval restores access for the ban being reviewed. A rejection keeps the ban. Staff leave a reason you can read on the same page. You cannot submit the same ban again after a rejection; contact the team if you have new information.\n\nIf an Admin changes the restriction while an appeal is waiting, the old appeal closes. An old approval cannot override a newer restriction.\n\n## Deleted accounts\n\nPermanent deletion removes sign-in, the profile, credentials and account appeals. It cannot be appealed or recovered through this page. Shared maps and community history can remain without an account owner; deletion does not erase the worlds other players use.\n\n## Staff controls\n\nOpen **Admin Tools** from the footer or go to **/admin**. This separate page is available only to Admins. Admins can search accounts, deactivate, ban, restore access, and permanently delete accounts. Every action needs a reason. Permanent deletion also requires typing the exact account ID. Admin accounts are protected from these controls.\n\nOpen **Moderation Tools** at **/moderation** to review maps and account ban appeals. Moderators and Admins can access this page. Moderators cannot deactivate, ban, delete, or directly restore arbitrary accounts."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Realtime evaluates these same SELECT policies for every INSERT/UPDATE event.
|
||||
-- Players may read their own appeal history; only active staff may read others.
|
||||
grant select on public.account_appeals to authenticated;
|
||||
create policy "Players and active staff read account appeals"
|
||||
on public.account_appeals for select to authenticated using (
|
||||
owner_id=(select auth.uid()) or exists (
|
||||
select 1 from public.profiles p
|
||||
where p.id=(select auth.uid()) and p.role in ('moderator','admin') and p.account_status='active'
|
||||
)
|
||||
);
|
||||
|
||||
-- Standalone SQL test databases do not configure the hosted Realtime publication.
|
||||
do $$ begin
|
||||
if exists(select 1 from pg_publication where pubname='supabase_realtime') then
|
||||
if not exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='account_appeals') then
|
||||
alter publication supabase_realtime add table public.account_appeals;
|
||||
end if;
|
||||
-- The existing own_profile policy restricts status changes to the owner.
|
||||
if not exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='profiles') then
|
||||
alter publication supabase_realtime add table public.profiles;
|
||||
end if;
|
||||
end if;
|
||||
end $$;
|
||||
@@ -79,6 +79,7 @@ test("appeals cannot choose their owner, ban version, reviewer or outcome", () =
|
||||
test("restricted page allowlist cannot admit game, dashboard or staff pages via prefixes", () => {
|
||||
for (const path of [
|
||||
"/ban/appeal",
|
||||
"/settings",
|
||||
"/docs",
|
||||
"/log-in",
|
||||
"/auth/callback",
|
||||
|
||||
52
tests/realtime-refresh.test.ts
Normal file
52
tests/realtime-refresh.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { realtimeRefresh } from "../lib/realtime-refresh";
|
||||
|
||||
test("Realtime refresh coalesces bursts and retains changes arriving during a request", async () => {
|
||||
const releases: Array<() => void> = [];
|
||||
let calls = 0;
|
||||
const queue = realtimeRefresh(
|
||||
() => {
|
||||
calls++;
|
||||
return new Promise<void>((resolve) => releases.push(resolve));
|
||||
},
|
||||
() => assert.fail("unexpected failure"),
|
||||
);
|
||||
const first = queue.request();
|
||||
await queue.request();
|
||||
await queue.request();
|
||||
assert.equal(calls, 1);
|
||||
releases.shift()!();
|
||||
await Promise.resolve();
|
||||
assert.equal(calls, 2, "One follow-up fetch captures the newest state");
|
||||
releases.shift()!();
|
||||
await first;
|
||||
assert.equal(calls, 2);
|
||||
queue.close();
|
||||
await queue.request();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("Realtime refresh recovers after a failed fetch and cancels queued work on unmount", async () => {
|
||||
const errors: unknown[] = [];
|
||||
let calls = 0;
|
||||
let release: () => void = () => {};
|
||||
const queue = realtimeRefresh(
|
||||
async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw Error("offline");
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
(e) => errors.push(e),
|
||||
);
|
||||
await queue.request();
|
||||
assert.equal(errors.length, 1);
|
||||
const second = queue.request();
|
||||
await queue.request();
|
||||
queue.close();
|
||||
release();
|
||||
await second;
|
||||
assert.equal(calls, 2, "Closing prevents the pending follow-up request");
|
||||
});
|
||||
Reference in New Issue
Block a user