Support Forge Server Login account management and registration prompts

This commit is contained in:
Aryan Singh committed 2026-10-16 20:44:39 -05:00
1 parent 683cdc4016
commit d0a696f013
5 files changed
+59 -7

No files matched your search

+10 -3
View File
@@ -21,6 +21,8 @@ type AccessData = {
approved: ApprovedPlayer[];
whitelist: string[] | null;
registered: string[] | null;
authProvider: "easyauth" | "serverlogin";
registrationCommand: string;
};
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
@@ -80,7 +82,7 @@ export default function AccessPage() {
<div>
<h1 className="text-xl font-bold">Access &amp; Auth</h1>
<p className="mt-0.5 text-xs text-ink2">
Only approved usernames can join. New players must <code>/register</code> within the window, then{" "}
Only approved usernames can join. New players must register within the window using <code>{data?.registrationCommand ?? "/register <password> <password>"}</code>, then{" "}
<code>/login</code> on every visit.
</p>
</div>
@@ -105,7 +107,7 @@ export default function AccessPage() {
</div>
<p className="mt-2 text-[11px] leading-relaxed text-muted">
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
<em>right away</em> and run <code>/register &lt;password&gt; &lt;password&gt;</code>. If the timer
<em>right away</em> and run <code>{data?.registrationCommand ?? "/register <password> <password>"}</code>. If the timer
expires first, the name is automatically removed from the whitelist.
</p>
</Card>
@@ -183,7 +185,7 @@ export default function AccessPage() {
<RefreshCw size={12} /> Re-open window
</Button>
)}
{p.status === "registered" && (
{p.status === "registered" && data?.authProvider !== "serverlogin" && (
<>
<Button
size="sm"
@@ -205,6 +207,11 @@ export default function AccessPage() {
</Button>
</>
)}
{p.status === "registered" && data?.authProvider === "serverlogin" && (
<Button size="sm" onClick={() => void run(`reregister-${p.username}`, { op: "force-reregister", username: p.username })} busy={busy === `reregister-${p.username}`}>
<KeyRound size={12} /> Re-register password
</Button>
)}
{p.status !== "revoked" && (
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
Revoke
+2 -2
View File
@@ -8,7 +8,7 @@ import {
forceReregister,
importFromWhitelist,
} from "@/server/authflow";
import { whitelistList, easyauthList } from "@/server/mc";
import { whitelistList, easyauthList, authProvider, registrationCommand } from "@/server/mc";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
@@ -23,7 +23,7 @@ export async function GET() {
try {
registered = await easyauthList();
} catch {}
return ok({ approved: listApproved(), whitelist, registered });
return ok({ approved: listApproved(), whitelist, registered, authProvider: authProvider(), registrationCommand: registrationCommand() });
});
}
+3 -2
View File
@@ -13,6 +13,7 @@ import {
easyauthList,
easyauthRemove,
easyauthSetPassword,
registrationCommand,
} from "./mc";
/**
@@ -72,7 +73,7 @@ export async function approvePlayer(username: string, note?: string): Promise<Ap
)
.run(username, now, expires, note ?? null);
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
notify("info", `Approved ${username}`, "They can now join and /register — the window is ticking.");
notify("info", `Approved ${username}`, `They can now join and use ${registrationCommand()} — the window is ticking.`);
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
}
@@ -127,7 +128,7 @@ export async function resetPassword(username: string): Promise<string> {
export async function forceReregister(username: string): Promise<void> {
await easyauthRemove(username);
try {
await kickPlayer(username, "Please rejoin and /register a new password");
await kickPlayer(username, `Please rejoin and use ${registrationCommand()} to choose a new password`);
} catch {}
await whitelistAdd(username);
db()
+22
View File
@@ -384,9 +384,30 @@ export async function kickAll(reason: string): Promise<number> {
// ── EasyAuth ────────────────────────────────────────────────────────────
export function authProvider() {
return fs.existsSync(path.join(env.mcDataDir, "config", "serverlogin-common.toml")) ? "serverlogin" : "easyauth";
}
export function registrationCommand() {
return authProvider() === "serverlogin" ? "/login <password> <password>" : "/register <password> <password>";
}
export function parseServerLoginAccounts(text: string): string[] {
const data = JSON.parse(text) as { formatVersion?: number; users?: Record<string, { lastKnownName?: string }> };
if (data.formatVersion !== 1 || !data.users || Array.isArray(data.users) || typeof data.users !== "object") throw new Error("Unsupported Server Login account database");
return Object.values(data.users).map(account => account?.lastKnownName).filter((name): name is string => typeof name === "string" && isValidUsername(name));
}
/** Registered account names according to EasyAuth. */
export async function easyauthList(): Promise<string[]> {
if (authProvider() === "serverlogin") {
// Confirm the server is reachable before reconciling registration windows.
await execChecked("auth count");
const file = path.join(env.mcDataDir, "world", "serverlogin", "users.json");
if (!fs.existsSync(file)) return [];
return parseServerLoginAccounts(fs.readFileSync(file, "utf8"));
}
const out = stripColors(await rcon().exec("auth list"));
if (/unknown|incorrect|expected/i.test(out)) throw new Error("Authentication provider did not return an account list");
// Output is a list of registered usernames; extract all name-like tokens
// after a colon if present, otherwise from the whole string.
const colon = out.indexOf(":");
@@ -404,6 +425,7 @@ export async function easyauthRemove(name: string): Promise<string> {
export async function easyauthSetPassword(name: string, password: string): Promise<string> {
assertValidUsername(name);
if (authProvider() === "serverlogin") throw new Error("Use Force re-register, then let the player choose a new password in-game");
if (!/^[\x21-\x7e]{1,64}$/.test(password)) throw new Error("Unsupported password characters");
return stripColors(await rcon().exec(`auth update ${name} ${password}`));
}
+22
View File
@@ -0,0 +1,22 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
vi.mock("../src/server/env", () => ({ env: { mcDataDir: "" } }));
vi.mock("../src/server/rcon", () => ({ rcon: () => ({ exec: vi.fn() }), stripColors: (s: string) => s, RconError: class extends Error {} }));
import { env } from "../src/server/env";
import { authProvider, registrationCommand, parseServerLoginAccounts, easyauthSetPassword } from "../src/server/mc";
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-auth-")); env.mcDataDir = dir; });
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
it("uses the Forge registration flow and never sends an unsupported password reset command", async () => {
expect(authProvider()).toBe("easyauth");
fs.mkdirSync(path.join(dir, "config")); fs.writeFileSync(path.join(dir, "config/serverlogin-common.toml"), "");
expect(registrationCommand()).toBe("/login <password> <password>");
await expect(easyauthSetPassword("aryan", "test-only")).rejects.toThrow("Force re-register");
});
it("returns names only from the documented account database and rejects unknown formats", () => {
expect(parseServerLoginAccounts(JSON.stringify({ formatVersion: 1, users: { aryan: { lastKnownName: "aryan", salt: "private", hash: "private" } } }))).toEqual(["aryan"]);
expect(() => parseServerLoginAccounts('{"formatVersion":2,"users":{}}')).toThrow();
expect(() => parseServerLoginAccounts('{"formatVersion":1,"users":[]}')).toThrow();
});