mirror of
https://github.com/horizon-rust/horizon-site.git
synced 2026-11-07 10:05:58 -10:00
Hub: /hub/appeal for banned users and official-server bans, and a Cases tab on /hub/mod
The appeal form reads Kira's appeal/* routes through the staff proxy: the link Kira DMs before a ban, or Discord sign-in once Kira has its client secret. Timeouts and quarantines are appealed on Discord. The Cases tab lists Kira's numbered mod cases and the appeals with their status (GET mod/cases). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f86477051e
commit
fb8b721adc
5 files changed
+374
-2
No files matched your search
@@ -28,7 +28,10 @@ npm run lint
|
||||
app/ the routes: /, /hub, /mods, /mods/<id>, /mods/submit, /mods/policy, /releases,
|
||||
/benchmarks, /stats, /docs, /docs/<page>, /privacy, /apply, and the staff's
|
||||
pages inside the hub: /hub/team, /hub/mod, /hub/sign-in, /hub/setup, /hub/staff-help
|
||||
(the old /staff/* addresses redirect there)
|
||||
(the old /staff/* addresses redirect there; /hub/mod has a Cases tab: Kira's numbered mod
|
||||
cases and appeals), and /hub/appeal: the appeal form for people banned from the Discord
|
||||
server or the official Minecraft server (the link Kira DMs, or Discord sign-in; Kira's
|
||||
appeal/* routes behind the same proxy; timeouts and quarantines are appealed on Discord)
|
||||
app/api/ the registry and players proxies, and release view counts
|
||||
app/staff/api/ the staff dashboards' proxy to Kira's service
|
||||
app/data/ /data/roadmap.json and /data/journal.json, which Kira reads
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AppealForm } from "@/components/appeal-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Appeal · Horizon hub",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer",
|
||||
};
|
||||
|
||||
export default function AppealPage() {
|
||||
return <AppealForm />;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, useSyncExternalStore, type FormEvent } from "react";
|
||||
import { api } from "@/components/staff/api";
|
||||
import { AREA, CARD, Field, INPUT, LINK, PRIMARY, SECONDARY, Status } from "@/components/staff/ui";
|
||||
|
||||
// /hub/appeal: for people banned from Horizon's Discord (they can't see the server) and players banned from the official
|
||||
// Minecraft server. Timeouts and quarantines are appealed inside Discord. Who you are comes from the link Kira DMs before a
|
||||
// ban, or from signing in with Discord. Kira's side: src/features/modcases.js (routes appeal/*).
|
||||
|
||||
type Question = { id: string; label: string; max: number; required?: boolean };
|
||||
type Case = { n: number; action: string; label: string; at: number; until?: number | null; reason?: string | null; ign?: string | null; canAppeal: boolean; why?: string | null };
|
||||
type Appeal = { id: string; case: number; label: string; at: number; status: "open" | "decided"; outcome?: string | null };
|
||||
type View = { name?: string | null; cases: Case[]; appeals: Appeal[]; questions: Question[] };
|
||||
|
||||
const KEY = "appeal:token";
|
||||
const never = () => () => {};
|
||||
const NOTES: Record<string, string> = {
|
||||
"signin-off": "Signing in with Discord isn't available yet. Use the link Kira sent you in a DM.",
|
||||
"signin-failed": "Signing in with Discord didn't work. Try again, or use the link Kira sent you in a DM.",
|
||||
};
|
||||
|
||||
function readHash() {
|
||||
try {
|
||||
const h = window.location.hash.slice(1);
|
||||
if (h) {
|
||||
window.history.replaceState(null, "", "/hub/appeal");
|
||||
if (NOTES[h]) sessionStorage.setItem(`${KEY}:note`, h);
|
||||
else localStorage.setItem(KEY, h);
|
||||
}
|
||||
return `${localStorage.getItem(KEY) ?? ""}|${sessionStorage.getItem(`${KEY}:note`) ?? ""}`;
|
||||
} catch {
|
||||
return "|";
|
||||
}
|
||||
}
|
||||
|
||||
function forget() {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
sessionStorage.removeItem(`${KEY}:note`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const day = (ms: number) => new Date(ms).toLocaleDateString([], { day: "numeric", month: "long", year: "numeric" });
|
||||
|
||||
export function AppealForm() {
|
||||
const read = useSyncExternalStore(never, readHash, () => null);
|
||||
const [token, note] = read === null ? [null, ""] : read.split("|");
|
||||
const [signIn, setSignIn] = useState<boolean | null>(null);
|
||||
const [view, setView] = useState<View | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [target, setTarget] = useState<string>("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api<{ signIn: boolean }>("POST", "appeal/config", {}).then((c) => setSignIn(c.signIn), () => setSignIn(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api<View>("POST", "appeal/peek", { token }).then(
|
||||
(v) => {
|
||||
setView(v);
|
||||
const first = v.cases.find((c) => c.canAppeal);
|
||||
setTarget(first ? String(first.n) : "minecraft");
|
||||
},
|
||||
(e: Error) => {
|
||||
forget();
|
||||
setError(e.message);
|
||||
},
|
||||
);
|
||||
}, [token, sent]);
|
||||
|
||||
const submit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!view || !token) return;
|
||||
const form = e.currentTarget;
|
||||
const value = (name: string) => (form.elements.namedItem(name) as HTMLInputElement | HTMLTextAreaElement | null)?.value ?? "";
|
||||
const answers = Object.fromEntries(view.questions.map((q) => [q.id, value(q.id)]));
|
||||
const body =
|
||||
target === "minecraft" ? { token, kind: "minecraft", ign: value("ign"), banText: value("banText"), answers } : { token, kind: "discord", case: Number(target), answers };
|
||||
setBusy(true);
|
||||
setStatus("Sending…");
|
||||
try {
|
||||
await api("POST", "appeal/submit", body);
|
||||
setSent(true);
|
||||
setStatus("");
|
||||
} catch (err) {
|
||||
setStatus((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInLink = signIn ? (
|
||||
// eslint-disable-next-line @next/next/no-html-link-for-pages -- a route that redirects to Discord's sign-in, not a page
|
||||
<a href="/staff/api/appeal/login" className={PRIMARY}>
|
||||
Sign in with Discord
|
||||
</a>
|
||||
) : null;
|
||||
|
||||
let body;
|
||||
if (token === null || (token && !view && !error)) {
|
||||
body = <p className="text-lg text-white/70">Checking…</p>;
|
||||
} else if (!token || error) {
|
||||
body = (
|
||||
<div className="max-w-2xl space-y-6 text-lg leading-relaxed text-white/70">
|
||||
{note ? <p>{NOTES[note]}</p> : null}
|
||||
{error ? <p>{error}</p> : null}
|
||||
<p>
|
||||
This page is for people banned from Horizon's Discord server, and for players banned from the official Minecraft server. A timeout or a
|
||||
quarantine is appealed on Discord: the <strong className="text-white">Open appeal</strong> button in Kira's DM, or in #appeal.
|
||||
</p>
|
||||
<p>{signIn ? "Sign in with Discord so the mods know who is appealing, or open the link Kira sent you in a DM." : "Open the link Kira sent you in a DM: it says who is appealing."}</p>
|
||||
{signInLink}
|
||||
</div>
|
||||
);
|
||||
} else if (view) {
|
||||
const open = view.cases.filter((c) => c.canAppeal);
|
||||
body = (
|
||||
<div className="space-y-8">
|
||||
<p className="max-w-2xl text-lg leading-relaxed text-white/70">
|
||||
{view.name ? (
|
||||
<>
|
||||
As <strong className="text-white">{view.name}</strong> on Discord.{" "}
|
||||
</>
|
||||
) : null}
|
||||
The mods read the form and decide; the one who acted never decides alone. Come back to this page to read the answer.
|
||||
</p>
|
||||
{view.appeals.length ? (
|
||||
<section className={`${CARD} max-w-3xl space-y-3`}>
|
||||
<h2 className="font-display text-xl font-semibold text-white">Your appeals</h2>
|
||||
<ul className="space-y-3">
|
||||
{view.appeals.map((a) => (
|
||||
<li key={a.id} className="text-[15px] leading-relaxed text-white/75">
|
||||
<span className="text-white">
|
||||
Case #{a.case} · {a.label}
|
||||
</span>{" "}
|
||||
<span className="text-white/45">sent {day(a.at)}</span>
|
||||
<p className="mt-1 whitespace-pre-line">{a.outcome ?? "Open: the mods haven't decided yet."}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{sent ? (
|
||||
<p className="text-lg text-white/75">Sent, thank you. The answer shows here once the mods decide.</p>
|
||||
) : (
|
||||
<form onSubmit={submit} className={`${CARD} max-w-3xl space-y-5`}>
|
||||
<Field label="What you are appealing">
|
||||
<select value={target} onChange={(e) => setTarget(e.target.value)} className={INPUT}>
|
||||
{open.map((c) => (
|
||||
<option key={c.n} value={c.n}>
|
||||
Case #{c.n}: {c.label} on {day(c.at)}
|
||||
{c.reason ? ` (${c.reason.slice(0, 60)})` : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="minecraft">A ban from the official Minecraft server</option>
|
||||
</select>
|
||||
</Field>
|
||||
{view.cases
|
||||
.filter((c) => !c.canAppeal && c.why)
|
||||
.map((c) => (
|
||||
<p key={c.n} className="text-[13px] text-white/50">
|
||||
Case #{c.n} ({c.label}): {c.why}
|
||||
</p>
|
||||
))}
|
||||
{target === "minecraft" ? (
|
||||
<>
|
||||
<Field label="Your in-game name">
|
||||
<input name="ign" required minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" className={INPUT} />
|
||||
</Field>
|
||||
<Field label="What the ban message said (optional)">
|
||||
<input name="banText" maxLength={300} className={INPUT} />
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
{view.questions.map((q) => (
|
||||
<Field key={q.id} label={`${q.label}${q.required ? "" : " (optional)"}`}>
|
||||
<textarea name={q.id} rows={4} maxLength={q.max} required={q.required} className={AREA} />
|
||||
</Field>
|
||||
))}
|
||||
<p className="text-[13px] leading-relaxed text-white/50">
|
||||
Only the mods (for the Minecraft server, its ops) and Ludovic read it. One appeal per action; another one 7 days after a decision (
|
||||
<Link href="/privacy" className={LINK}>
|
||||
privacy
|
||||
</Link>
|
||||
).
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={busy} className={PRIMARY}>
|
||||
Send my appeal
|
||||
</button>
|
||||
<Status text={status} />
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY}
|
||||
onClick={() => {
|
||||
forget();
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-6 pt-32 pb-24 sm:pt-36 lg:px-10">
|
||||
<p className="text-sm font-medium text-white/60">
|
||||
Hub <span className="mx-1.5 text-white/30">·</span> Appeal
|
||||
</p>
|
||||
<h1 className="mt-3 mb-6 font-display text-[clamp(2rem,5vw,3.25rem)] leading-[1.05] font-semibold tracking-[-0.03em] text-white">Appeal a ban</h1>
|
||||
{body}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, when, type People } from "@/components/staff/api";
|
||||
import { BADGE, CARD, Empty } from "@/components/staff/ui";
|
||||
|
||||
// The Moderation dashboard's Cases tab: Kira's numbered cases (#mod-actions) and the appeals, with their status.
|
||||
// Kira's side: GET /staff/api/mod/cases (src/features/modcases.js); no appeal answers here, they are in #mod-mail.
|
||||
|
||||
type Case = {
|
||||
n: number;
|
||||
label: string;
|
||||
userId: string;
|
||||
tag?: string | null;
|
||||
by?: string | null;
|
||||
reason?: string | null;
|
||||
at: number;
|
||||
until?: number | null;
|
||||
ign?: string | null;
|
||||
evidence?: string | null;
|
||||
lifted?: boolean;
|
||||
url?: string | null;
|
||||
appeal?: { status: "open" | "decided"; decision?: string | null; url?: string | null } | null;
|
||||
};
|
||||
type Appeal = { id: string; case: number; label: string; via: string; userId: string; at: number; status: "open" | "decided"; url?: string | null; decision?: string | null; decidedBy?: string | null };
|
||||
type CasesView = { cases: Case[]; appeals: Appeal[]; people?: People };
|
||||
|
||||
const DECIDED: Record<string, string> = { uphold: "upheld", ban: "upheld, banned", shorten: "shortened", lift: "lifted" };
|
||||
const discordUrl = (u?: string | null) => (u && /^https:\/\/discord\.com\/channels\/\d+\/\d+\/\d+$/.test(u) ? u : undefined);
|
||||
|
||||
function Who({ id, people, fallback }: { id?: string | null; people: People; fallback?: string | null }) {
|
||||
if (!id) return <span className="text-white/45">unknown</span>;
|
||||
if (id === "kira") return <span>Kira (automatic)</span>;
|
||||
return <span>{people[id] ?? fallback ?? id}</span>;
|
||||
}
|
||||
|
||||
function AppealBadge({ a }: { a: Case["appeal"] | Appeal | null | undefined }) {
|
||||
if (!a) return null;
|
||||
const text = a.status === "open" ? "appeal open" : `appeal ${DECIDED[a.decision ?? ""] ?? "decided"}`;
|
||||
return <span className={`${BADGE} ${a.status === "open" ? "bg-[#f0932b]/20 text-[#ffd3a6]" : ""}`}>{text}</span>;
|
||||
}
|
||||
|
||||
export function Cases() {
|
||||
const [view, setView] = useState<CasesView | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
api<CasesView>("GET", "mod/cases").then(setView, (e: Error) => setError(e.message));
|
||||
}, []);
|
||||
if (error) return <p className="text-[15px] text-white/70">{error}</p>;
|
||||
if (!view) return <p className="text-sm text-white/50">Loading…</p>;
|
||||
const people = view.people ?? {};
|
||||
const open = view.appeals.filter((a) => a.status === "open");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[15px] text-white/60">
|
||||
Every timeout, quarantine, ban, unban and kick, by a mod or by Kira, as Kira posts it in #mod-actions. Appeals are decided in their #mod-mail
|
||||
thread by a mod who didn't act.
|
||||
</p>
|
||||
<section className={CARD}>
|
||||
<h2 className="font-display text-lg font-semibold text-white">Open appeals ({open.length})</h2>
|
||||
{open.length ? (
|
||||
<ul className="mt-3 space-y-2 text-[15px] text-white/75">
|
||||
{open.map((a) => (
|
||||
<li key={a.id}>
|
||||
<a href={discordUrl(a.url)} className="text-white hover:underline" target="_blank" rel="noreferrer">
|
||||
Case #{a.case} · {a.label}
|
||||
</a>{" "}
|
||||
by <Who id={a.userId} people={people} /> <span className="text-white/45">· {when(a.at)} · {a.via === "web" ? "from the website" : "from Discord"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Empty>No appeal waiting.</Empty>
|
||||
)}
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
<h2 className="font-display text-lg font-semibold text-white">Cases</h2>
|
||||
{view.cases.length ? (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-left text-[14px] text-white/75">
|
||||
<thead className="text-xs text-white/45">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">#</th>
|
||||
<th className="py-2 pr-3 font-medium">Action</th>
|
||||
<th className="py-2 pr-3 font-medium">Member</th>
|
||||
<th className="py-2 pr-3 font-medium">By</th>
|
||||
<th className="py-2 pr-3 font-medium">Reason</th>
|
||||
<th className="py-2 pr-3 font-medium">When</th>
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.cases.map((c) => (
|
||||
<tr key={c.n} id={`case-${c.n}`} className="border-t border-white/[0.06] align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<a href={discordUrl(c.url)} className="text-white hover:underline" target="_blank" rel="noreferrer">
|
||||
{c.n}
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap">{c.label}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<Who id={c.userId} people={people} fallback={c.tag} />
|
||||
{c.ign ? <span className="text-white/45"> · {c.ign}</span> : null}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<Who id={c.by} people={people} />
|
||||
</td>
|
||||
<td className="max-w-[24rem] py-2 pr-3 break-words">
|
||||
{c.reason || <span className="text-white/45">none given</span>}
|
||||
{!c.evidence && c.by && c.by !== "kira" ? <span className="text-white/45"> · no evidence yet</span> : null}
|
||||
</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap text-white/55">{when(c.at)}</td>
|
||||
<td className="space-x-1 py-2">
|
||||
{c.lifted ? <span className={BADGE}>lifted</span> : null}
|
||||
<AppealBadge a={c.appeal} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<Empty>No case yet: they start with the next timeout, quarantine, ban or kick.</Empty>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Applications } from "@/components/staff/applications";
|
||||
import { Cases } from "@/components/staff/cases";
|
||||
import { api, type Application, type People, type Who } from "@/components/staff/api";
|
||||
import { BarChart, DataSet, Tile, fmt, type Row } from "@/components/staff/charts";
|
||||
import { selectTab, tabFromHash, useHash, useScrollToHash } from "@/components/staff/hash";
|
||||
import { Inbox, inboxCount, inboxGroups } from "@/components/staff/inbox";
|
||||
import { Guard, Panel, Tabs } from "@/components/staff/ui";
|
||||
|
||||
const IDS = ["inbox", "applications", "stats"];
|
||||
const IDS = ["inbox", "cases", "applications", "stats"];
|
||||
const LABELS: Record<string, string> = {
|
||||
reports: "Reports",
|
||||
modmail: "Mod-mail threads",
|
||||
@@ -105,6 +106,7 @@ export function ModDashboard({ who }: { who: Who }) {
|
||||
const count = apps ? inboxCount(inboxGroups({ apps, me: who.user, names: [], act: () => {} }).groups) : 0;
|
||||
const tabs = [
|
||||
{ id: "inbox", label: "Inbox", count },
|
||||
{ id: "cases", label: "Cases" },
|
||||
{ id: "applications", label: "Applications" },
|
||||
{ id: "stats", label: "Stats" },
|
||||
];
|
||||
@@ -130,6 +132,9 @@ export function ModDashboard({ who }: { who: Who }) {
|
||||
<Inbox dashboard="mod" apps={apps} me={who.user} names={[]} onDone={load} />
|
||||
</Guard>
|
||||
</Panel>
|
||||
<Panel id="cases" active={tab}>
|
||||
<Guard what="The cases">{tab === "cases" ? <Cases /> : null}</Guard>
|
||||
</Panel>
|
||||
<Panel id="applications" active={tab}>
|
||||
<Applications apps={apps} people={people} dashboard="mod" onDone={load} />
|
||||
</Panel>
|
||||
|
||||
Reference in new issue
Block a user