Files
Felis/panel/src/components/players/WhitelistSection.tsx
T
Lemon-miaow 0790f8dfd3 fix(panel): surface the RCON reply for player-access mutations (#56)
The whitelist/ban/kick mutations reported a canned success message and threw
away the server's reply, so a refused command still read as done: live, the
vanilla server answers "That player does not exist" for a name it has never
seen (any player who has not joined yet), while the panel said the player had
been whitelisted/banned. api.ts documents these replies as "surfaced verbatim
as confirmation"; now they are. The localized string stays as the fallback
for a silent server.
2026-09-23 22:08:19 +08:00

235 lines
9.1 KiB
TypeScript

import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ListChecks, Loader2, Plus, RotateCw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InlineConfirm } from "@/components/InlineConfirm";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import {
CollapsibleSection,
FeedbackLine,
MC_NAME,
PagerFooter,
PlayerField,
rconReply,
SearchBox,
usePagedNames,
type Feedback,
} from "./shared";
/** WhitelistSection manages the server's join whitelist: add a name, remove any
* entry, and read the current list. Built to stay usable at a few hundred names —
* a live count, a filter and paging (via usePagedNames) once the list is long
* enough to need them, and a two-step remove so a name never vanishes on one tap. */
export function WhitelistSection({ name, defaultOpen = false }: { name: string; defaultOpen?: boolean }) {
const { t } = useTranslation("servers");
const { data, error, loading, reload } = useAsync(
() => api.accessWhitelistList(name),
[name],
);
const [value, setValue] = useState("");
const [touched, setTouched] = useState(false);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const [fb, setFb] = useState<Feedback>(null);
const player = value.trim();
const valid = MC_NAME.test(player);
const invalid = touched && player.length > 0 && !valid;
const players = useMemo(() => data?.players ?? [], [data]);
// The server always echoes the raw RCON text. Parsed `players` drives the list /
// empty state; `raw` is kept as an always-available, low-key disclosure — it is
// ground truth when the vanilla-only parse can't tokenize a plugin or localized
// whitelist (the names are in `output` even when `players` came back empty).
const raw = data?.output?.trim() ?? "";
const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } =
usePagedNames(players);
const add = useCallback(async () => {
if (!valid || adding) {
setTouched(true);
return;
}
setFb(null);
setAdding(true);
try {
const res = await api.accessWhitelist(name, "add", player);
setFb({
kind: "ok",
msg: rconReply(res.output, t("access_whitelist_added", { player })),
});
setValue("");
setTouched(false);
reload();
} catch (e) {
setFb({ kind: "err", msg: humanizeError(e) });
} finally {
setAdding(false);
}
}, [name, player, valid, adding, reload, t]);
const remove = useCallback(
async (p: string) => {
setFb(null);
setRemoving(p);
try {
const res = await api.accessWhitelist(name, "remove", p);
setFb({
kind: "ok",
msg: rconReply(res.output, t("access_whitelist_removed", { player: p })),
});
reload();
} catch (e) {
setFb({ kind: "err", msg: humanizeError(e) });
} finally {
setRemoving(null);
setConfirming(null);
}
},
[name, reload, t],
);
return (
<CollapsibleSection
icon={<ListChecks className="h-4 w-4" />}
title={t("access_whitelist_title")}
count={!loading && !error ? players.length : undefined}
defaultOpen={defaultOpen}
actions={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
onClick={reload}
disabled={loading}
title={t("access_refresh")}
aria-label={t("access_refresh")}
>
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("access_whitelist_desc")}</p>
{/* Add row — the primary action, kept at the top so it is always in reach. */}
<div className="space-y-1.5">
<div className="flex items-start gap-2">
<div className="flex-1">
<PlayerField
value={value}
onChange={setValue}
onEnter={add}
invalid={invalid}
disabled={adding}
/>
</div>
<Button size="sm" className="h-9" onClick={add} disabled={!valid || adding}>
{adding ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("access_whitelist_add")}
</Button>
</div>
{invalid && <p className="text-xs text-destructive">{t("access_player_invalid")}</p>}
</div>
{loading && !data ? (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
</div>
) : error ? (
<p className="text-xs text-destructive">{t("access_whitelist_load_error")}</p>
) : players.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
<p className="mt-1 text-xs text-muted-foreground/80">
{t("access_whitelist_empty_hint")}
</p>
</div>
) : (
<div className={cn("space-y-2 transition-opacity", loading && "opacity-60 pointer-events-none")}>
{showSearch && <SearchBox value={query} onChange={onQuery} />}
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{shown.length === 0 ? (
<div className="col-span-full py-6 text-center text-xs text-muted-foreground">
{t("access_search_no_match", { query: query.trim() })}
</div>
) : (
pageItems.map((p) => (
<div
key={p}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card/25 p-2.5 transition-all hover:border-primary/20 hover:bg-accent/40"
>
<span className="truncate font-mono text-sm font-medium">{p}</span>
{/* Removal asks once before it fires: one click arms the row (X →
取消 / 移除), a second confirms. Recoverable, but a name gone on
a single stray tap is exactly the surprise to avoid. */}
{confirming === p ? (
<InlineConfirm
open={true}
confirming={removing !== null}
onConfirm={() => remove(p)}
onCancel={() => setConfirming(null)}
confirmLabel={t("access_remove")}
cancelLabel={t("access_cancel")}
size="sm"
className="flex shrink-0 items-center gap-1"
/>
) : (
<button
type="button"
onClick={() => setConfirming(p)}
disabled={removing !== null}
aria-label={t("access_whitelist_remove", { player: p })}
title={t("access_whitelist_remove", { player: p })}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))
)}
</div>
{needFooter && (
<PagerFooter
q={q}
shownCount={shown.length}
total={players.length}
pageCount={pageCount}
clampedPage={clampedPage}
onPage={setPage}
/>
)}
</div>
)}
{/* The server's verbatim reply, kept as an opt-in disclosure — the escape
hatch when the vanilla-only parse can't tokenize a plugin or localized
whitelist. Collapsed by default so it never clutters the common case. */}
{!loading && !error && raw && (
<details className="text-xs">
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
{t("access_whitelist_raw")}
</summary>
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
{raw}
</pre>
</details>
)}
<FeedbackLine fb={fb} />
</div>
</CollapsibleSection>
);
}