fix(panel): 服主在控制台看得到 LuckPerms 入口,归属判定统一走 /me/servers
This commit is contained in:
10 files changed
+108
-28
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user