fix(panel): 服主在控制台看得到 LuckPerms 入口,归属判定统一走 /me/servers

This commit is contained in:
Lemon-miaow committed 2026-09-24 15:23:20 +08:00
1 parent c7db7d4126
commit 248fa5d100
10 files changed
+108 -28

No files matched your search

+3 -1
View File
@@ -1582,7 +1582,9 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
}
if (is("GET", ctx) && ctx.parts[4] === "status") {
sendJSON(ctx.res, 200, projectServer(serverInfo, ctx.account));
// The real status projection (api.ServerInfo) carries no owned/claimable.
const { owned: _o, claimable: _c, ...status } = projectServer(serverInfo, ctx.account);
sendJSON(ctx.res, 200, status);
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "console") {
+2 -1
View File
@@ -20,6 +20,7 @@ import type {
ServerFileEntry,
ServerJob,
ServerInfo,
ServerStatus,
SessionView,
UserDetail,
UserView,
@@ -200,7 +201,7 @@ export const api = {
fleet: () =>
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
status: (name: string) => request<ServerInfo>("GET", `/servers/${name}/status`),
status: (name: string) => request<ServerStatus>("GET", `/servers/${name}/status`),
wake: (name: string) =>
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`),
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { canManage, ownershipPending } from "./ownership";
import type { ServerInfo } from "./types";
// The owner-tier gate once read `owned` off /servers/{name}/status, which never
// sends it, so owners lost the LuckPerms entry. These cases pin that ownership
// comes only from the /me/servers row for this exact server.
const row = (name: string, owned?: boolean): ServerInfo => ({ name, subdomain: name, phase: "Running", owned });
describe("canManage", () => {
it("lets an admin in without any /me/servers rows", () => {
expect(canManage(true, null, "lobby")).toBe(true);
});
it("lets the owner in from their /me/servers row", () => {
expect(canManage(false, [row("other", false), row("lobby", true)], "lobby")).toBe(true);
});
it("keeps a non-owner out", () => {
expect(canManage(false, [row("lobby", false)], "lobby")).toBe(false);
expect(canManage(false, [row("lobby")], "lobby")).toBe(false);
expect(canManage(false, [row("other", true)], "lobby")).toBe(false);
});
it("keeps everyone out while /me/servers is unanswered", () => {
expect(canManage(false, null, "lobby")).toBe(false);
expect(canManage(false, undefined, "lobby")).toBe(false);
});
});
describe("ownershipPending", () => {
it("waits for the tier", () => {
expect(ownershipPending(true, false, [], null)).toBe(true);
});
it("waits for a non-admin's /me/servers", () => {
expect(ownershipPending(false, false, null, null)).toBe(true);
expect(ownershipPending(false, false, [], null)).toBe(false);
});
it("stops waiting once the read failed, so the page can offer a retry", () => {
expect(ownershipPending(false, false, null, { status: 503 })).toBe(false);
});
it("never waits on an admin", () => {
expect(ownershipPending(false, true, null, null)).toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { ServerInfo } from "./types";
// Owner-tier pages (console extras, players, files, backups, LuckPerms) decide
// who may act from GET /me/servers: the status projection never carries
// `owned`, so reading it there hides owner tools from every non-admin owner.
/** canManage reports whether the caller may use a server's owner-tier tools. */
export function canManage(
isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined,
name: string,
): boolean {
return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
}
/** ownershipPending is true while the answer is still unknown: the tier is
* loading, or a non-admin's /me/servers read has neither landed nor failed. */
export function ownershipPending(
tierLoading: boolean,
isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined,
mineError: unknown,
): boolean {
return tierLoading || (!isAdmin && mine == null && !mineError);
}
+6 -2
View File
@@ -17,8 +17,8 @@ export type Phase =
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** ServerInfo is the projection returned by GET /servers/{name}/status and
* GET /me/servers (the latter wraps a list under { servers: [...] }). */
/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server. */
export interface ServerInfo {
name: string;
subdomain: string;
@@ -38,6 +38,10 @@ export interface ServerInfo {
cpu?: string;
}
/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
* `claimable`; owner-tier gates read /me/servers via lib/ownership. */
export type ServerStatus = Omit<ServerInfo, "owned" | "claimable">;
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done
* server-side (parseWhitelistOutput); `output` is the raw RCON text and is the
+4 -3
View File
@@ -30,6 +30,7 @@ import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership";
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { BackupView } from "@/lib/types";
@@ -376,8 +377,8 @@ export function ServerBackups() {
// While it is pending show the header with a spinner rather than flashing the list
// at someone who may not own it; if that read itself failed, break to a retry so a
// real owner never fails closed to NotYours on a transient blip.
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
const pending = ownershipPending(tierLoading, isAdmin, mineQ.data, mineQ.error);
const owned = canManage(isAdmin, mineQ.data, name);
// The async world-operation history (the backup/restore Jobs behind every 202).
// Read only once the viewer is resolved as owner-or-admin (the route 403s
@@ -482,7 +483,7 @@ export function ServerBackups() {
<>
{back}
{header}
{ownershipPending ? (
{pending ? (
<Loading />
) : mineQ.error ? (
<ErrorState error={mineQ.error} onRetry={mineQ.reload} />
+7 -1
View File
@@ -12,6 +12,7 @@ import { Loading, ErrorState } from "@/components/States";
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage } from "@/lib/ownership";
import { hostFor } from "@/lib/config";
import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog";
@@ -193,6 +194,11 @@ export function ServerConsole() {
() => api.status(name),
[name],
);
const { data: mine } = useAsync(
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
[isAdmin, name],
);
const owned = canManage(isAdmin, mine, name);
// The read-side stream (spec §8) follows the running pod's log. A pod exists
// during BOTH Starting and Running — the operator marks Starting once the pod
@@ -283,7 +289,7 @@ export function ServerConsole() {
onUpdated={reload}
/>
)}
{(data.owned || isAdmin) && (
{owned && (
<Link
to={`/servers/${name}/luckperms`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
+4 -3
View File
@@ -29,6 +29,7 @@ import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership";
import { formatBytes, formatRelative } from "@/lib/format";
import type { ServerFileEntry } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -93,8 +94,8 @@ export function ServerFiles() {
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
[isAdmin, name],
);
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
const pending = ownershipPending(tierLoading, isAdmin, mineQ.data, mineQ.error);
const owned = canManage(isAdmin, mineQ.data, name);
const stopped = statusQ.data?.phase === "Stopped";
const [dir, setDir] = useState("");
@@ -241,7 +242,7 @@ export function ServerFiles() {
<>
{back}
{header}
{ownershipPending ? (
{pending ? (
<Loading />
) : mineQ.error ? (
<ErrorState error={mineQ.error} onRetry={mineQ.reload} />
+4 -12
View File
@@ -21,6 +21,7 @@ import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -299,8 +300,8 @@ export function ServerLuckPerms() {
}
if (!data) return back;
const ownershipPending = ownershipPendingCheck(tierLoading, isAdmin, mine, mineError);
const owned = isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
const owned = canManage(isAdmin, mine, name);
const phase: Phase = data.phase;
const header = (
@@ -317,7 +318,7 @@ export function ServerLuckPerms() {
<>
{back}
{header}
{ownershipPending ? (
{pending ? (
<Loading />
) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} />
@@ -801,12 +802,3 @@ export function ServerLuckPerms() {
</>
);
}
function ownershipPendingCheck(
tierLoading: boolean,
isAdmin: boolean,
mine: any[] | null,
mineError: any
): boolean {
return tierLoading || (!isAdmin && mine === null && !mineError);
}
+4 -5
View File
@@ -11,6 +11,7 @@ import { BansSection } from "@/components/players/BansSection";
import { api } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership";
import type { Phase } from "@/lib/types";
/** ServerPlayers is the per-server player-management subpage (/servers/:name/players):
@@ -59,10 +60,8 @@ export function ServerPlayers() {
// /me/servers read itself failed, `mineError` breaks the pending state (below) so a
// real owner sees a retry instead of an eternal spinner — never fail closed to
// NotYours, which would wrongly tell an owner the server isn't theirs on a blip.
const ownershipPending =
tierLoading || (!isAdmin && mine === null && !mineError);
const owned =
isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
const owned = canManage(isAdmin, mine, name);
const phase: Phase = data.phase;
const header = (
@@ -78,7 +77,7 @@ export function ServerPlayers() {
<>
{back}
{header}
{ownershipPending ? (
{pending ? (
<Loading />
) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} />