fix(allowlist): 唤醒名单可查看、可取消或恢复唤醒权限,换主人时清空

This commit is contained in:
Lemon-miaow committed 2026-09-27 02:55:30 +08:00
1 parent d807fd5887
commit 4a26bf4ca0
23 files changed
+1190 -32

No files matched your search

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WakeListSection } from "./WakeListSection";
import type { AllowlistEntry } from "@/lib/types";
const calls = vi.hoisted(() => ({ serverAllowlist: vi.fn(), setAllowlistWake: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const STEVE = "0f8fad5b-d9cb-469f-a165-70867728950e";
const GHOST = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
const ALEX = "16fd2706-8baf-433b-82eb-8c7fada847da";
const joined = new Date(Date.now() - 3 * 86400_000).toISOString();
const entry = (mc_uuid: string, can_wake: boolean, username?: string): AllowlistEntry => ({
mc_uuid,
username,
added_at: joined,
can_wake,
});
beforeEach(() => {
calls.serverAllowlist.mockReset();
calls.setAllowlistWake.mockReset();
});
function rowOf(label: string) {
return screen.getByText(label).closest("li") as HTMLElement;
}
describe("WakeListSection", () => {
it("lists who may wake the server, marks revoked players and names unlinked ones", async () => {
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
const steve = await screen.findByText("Steve");
expect(calls.serverAllowlist).toHaveBeenCalledWith("lobby");
const steveRow = steve.closest("li") as HTMLElement;
expect(within(steveRow).queryByText("Revoked")).toBeNull();
expect(within(steveRow).getByText("0f8fad5b").getAttribute("title")).toBe(STEVE);
expect(within(steveRow).getByText("first joined 3 days ago")).toBeTruthy();
expect(within(steveRow).getByRole("button", { name: "Take away Steve's right to wake the server" }).textContent).toBe(
"Revoke",
);
const ghostRow = rowOf("Player without a linked account");
expect(within(ghostRow).getByText("Revoked")).toBeTruthy();
expect(
within(ghostRow).getByRole("button", { name: "Allow Player without a linked account to wake the server again" })
.textContent,
).toBe("Allow again");
// The list is in effect: no note about the policy.
expect(screen.queryByText(/this list has no effect/)).toBeNull();
});
it("says the list has no effect under the other two policies", async () => {
calls.serverAllowlist.mockResolvedValue([]);
const owner = render(<WakeListSection name="lobby" policy="ownerOnly" defaultOpen />);
expect(await screen.findByText(/Owner only, so only you and admins/)).toBeTruthy();
expect(screen.getByText("No player has joined this server yet.")).toBeTruthy();
owner.unmount();
render(<WakeListSection name="lobby" policy="public" defaultOpen />);
expect(await screen.findByText(/Public, so any player's join starts it/)).toBeTruthy();
});
it("revokes and restores in place, reading the list again after each change", async () => {
const user = userEvent.setup();
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
calls.setAllowlistWake.mockResolvedValue(undefined);
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await screen.findByText("Steve");
calls.serverAllowlist.mockResolvedValue([entry(STEVE, false, "Steve"), entry(GHOST, false)]);
await user.click(screen.getByRole("button", { name: "Take away Steve's right to wake the server" }));
expect(calls.setAllowlistWake).toHaveBeenCalledWith("lobby", STEVE, false);
expect((await screen.findByRole("status")).textContent).toBe("Steve can no longer wake the server.");
expect(await within(rowOf("Steve")).findByText("Revoked")).toBeTruthy();
expect(calls.serverAllowlist).toHaveBeenCalledTimes(2);
calls.serverAllowlist.mockResolvedValue([entry(STEVE, false, "Steve"), entry(GHOST, true)]);
await user.click(
screen.getByRole("button", { name: "Allow Player without a linked account to wake the server again" }),
);
expect(calls.setAllowlistWake).toHaveBeenLastCalledWith("lobby", GHOST, true);
expect((await screen.findByRole("status")).textContent).toBe(
"Player without a linked account can wake the server again.",
);
});
it("reports a refused change and a failed read", async () => {
const user = userEvent.setup();
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve")]);
calls.setAllowlistWake.mockRejectedValue({ status: 403, code: "forbidden", message: "forbidden" });
const first = render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await user.click(await screen.findByRole("button", { name: "Take away Steve's right to wake the server" }));
expect((await screen.findByRole("alert")).textContent).toBe("You are not allowed to do that.");
expect(within(rowOf("Steve")).queryByText("Revoked")).toBeNull();
first.unmount();
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
expect((await screen.findByRole("alert")).textContent).toBe("Couldn't load the wake list.");
});
it("finds a player by account name or by UUID once the list is long", async () => {
const user = userEvent.setup();
const many = Array.from({ length: 9 }, (_, i) =>
entry(`00000000-0000-0000-0000-00000000000${i}`, true, `Player${i}`),
);
calls.serverAllowlist.mockResolvedValue([...many, entry(ALEX, true, "Alex")]);
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await screen.findByText("Player0");
// Ten a page: Alex, tenth, is on the first page until a search narrows it.
const search = screen.getByPlaceholderText("Search players…");
await user.type(search, "alex");
expect(screen.getByText("Alex")).toBeTruthy();
expect(screen.queryByText("Player0")).toBeNull();
await user.clear(search);
await user.type(search, "8baf-433b");
expect(screen.getByText("Alex")).toBeTruthy();
expect(screen.queryByText("Player1")).toBeNull();
});
});
describe("WakeListSection while a change is in flight", () => {
it("holds every row's button until the change lands, so two cannot cross", async () => {
const user = userEvent.setup();
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
let land: () => void = () => {};
calls.setAllowlistWake.mockReturnValue(new Promise<void>((resolve) => (land = resolve)));
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await user.click(await screen.findByRole("button", { name: "Take away Steve's right to wake the server" }));
const other = within(rowOf("Player without a linked account")).getByRole("button");
expect((other as HTMLButtonElement).disabled).toBe(true);
land();
await screen.findByRole("status");
expect((other as HTMLButtonElement).disabled).toBe(false);
});
});
@@ -0,0 +1,192 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlarmClock, Loader2, RotateCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { api, humanizeError } from "@/lib/api";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { useAsync } from "@/lib/hooks";
import type { AllowlistEntry, AutostartPolicy } from "@/lib/types";
import { cn } from "@/lib/utils";
import { CollapsibleSection, FeedbackLine, PagerFooter, SearchBox, usePagedNames, type Feedback } from "./shared";
/** WakeListSection shows who may wake the server while it sleeps under the "wake
* list" autostart policy, and lets the owner take that right away or give it back.
* Felis keeps this list itself, so unlike the other player blocks it works whether
* the server is running or not — which is when it matters most. A player lands on
* it by joining once; a revoked row stays (marked) so a rejoin cannot undo the
* owner's choice, which is also why the action is a reversible toggle and needs
* no confirm step. */
export function WakeListSection({
name,
policy,
defaultOpen = false,
}: {
name: string;
policy?: AutostartPolicy;
defaultOpen?: boolean;
}) {
const { t, i18n } = useTranslation("servers");
const { data, error, loading, reload } = useAsync(() => api.serverAllowlist(name), [name]);
const [busy, setBusy] = useState<string | null>(null);
const [fb, setFb] = useState<Feedback>(null);
const entries = useMemo(() => data ?? [], [data]);
const label = useCallback((e: AllowlistEntry) => e.username || t("wake_list_unlinked"), [t]);
// The shared list engine filters strings; each entry is searchable by its account
// name and its UUID, and the key maps a page back to its rows.
const byKey = useMemo(
() => new Map(entries.map((e) => [`${e.username ?? ""}\u0000${e.mc_uuid}`, e])),
[entries],
);
const keys = useMemo(() => [...byKey.keys()], [byKey]);
const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } =
usePagedNames(keys);
const toggle = useCallback(
async (e: AllowlistEntry) => {
const player = label(e);
setFb(null);
setBusy(e.mc_uuid);
try {
await api.setAllowlistWake(name, e.mc_uuid, !e.can_wake);
setFb({
kind: "ok",
msg: t(e.can_wake ? "wake_list_revoked" : "wake_list_restored", { player }),
});
reload();
} catch (err) {
setFb({ kind: "err", msg: humanizeError(err) });
} finally {
setBusy(null);
}
},
[name, label, reload, t],
);
const now = Date.now();
const inactive = policy === "ownerOnly" || policy === "public";
return (
<CollapsibleSection
icon={<AlarmClock className="h-4 w-4" />}
title={t("wake_list_title")}
count={!loading && !error ? entries.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("wake_list_desc")}</p>
{inactive && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{t(policy === "public" ? "wake_list_inactive_public" : "wake_list_inactive_owner")}
</p>
)}
{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 role="alert" className="text-xs text-destructive">
{t("wake_list_load_error")}
</p>
) : entries.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("wake_list_empty")}</p>
<p className="mt-1 text-xs text-muted-foreground/80">{t("wake_list_empty_hint")}</p>
</div>
) : (
<div className={cn("space-y-2 transition-opacity", loading && "pointer-events-none opacity-60")}>
{showSearch && <SearchBox value={query} onChange={onQuery} />}
<ul className="grid grid-cols-1 gap-2.5 md:grid-cols-2">
{shown.length === 0 ? (
<li className="col-span-full py-6 text-center text-xs text-muted-foreground">
{t("access_search_no_match", { query: query.trim() })}
</li>
) : (
pageItems.map((key) => {
const e = byKey.get(key)!;
const player = label(e);
return (
<li
key={e.mc_uuid}
className={cn(
"flex items-center justify-between gap-3 rounded-lg border border-border p-2.5 transition-colors",
e.can_wake ? "bg-card/25 hover:bg-accent/40" : "bg-muted/30",
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"truncate text-sm font-medium",
(!e.username || !e.can_wake) && "text-muted-foreground",
)}
>
{player}
</span>
{!e.can_wake && (
<Badge variant="muted" className="shrink-0 font-normal">
{t("wake_list_revoked_badge")}
</Badge>
)}
</div>
<p className="truncate text-[11px] text-muted-foreground">
<span className="font-mono" title={e.mc_uuid}>
{e.mc_uuid.slice(0, 8)}
</span>
{" · "}
<span title={formatAbsolute(e.added_at, i18n.language)}>
{t("wake_list_joined", { when: formatRelative(e.added_at, now, i18n.language) })}
</span>
</p>
</div>
<Button
variant={e.can_wake ? "ghost" : "outline"}
size="sm"
className={cn("h-8 shrink-0", e.can_wake && "text-muted-foreground hover:text-destructive")}
onClick={() => toggle(e)}
disabled={busy !== null}
aria-label={t(e.can_wake ? "wake_list_revoke_aria" : "wake_list_restore_aria", { player })}
>
{busy === e.mc_uuid && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t(e.can_wake ? "wake_list_revoke" : "wake_list_restore")}
</Button>
</li>
);
})
)}
</ul>
{needFooter && (
<PagerFooter
q={q}
shownCount={shown.length}
total={entries.length}
pageCount={pageCount}
clampedPage={clampedPage}
onPage={setPage}
/>
)}
</div>
)}
<FeedbackLine fb={fb} />
</div>
</CollapsibleSection>
);
}