fix(allowlist): 唤醒名单可查看、可取消或恢复唤醒权限,换主人时清空
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user