Merge pull request #915 from Cooldude2606/feature/player-auth-display-version

Add version and connect button to player auth
This commit is contained in:
Daniel authored and GitHub committed 2026-07-18 23:29:14 +02:00
commit 599cf3ed22
5 files changed
+286 -66

No files matched your search

+27 -5
View File
@@ -3,11 +3,12 @@ import express, { type Request, type Response } from "express";
import util from "util";
import jwt from "jsonwebtoken";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
const { basicType } = lib;
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages";
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages";
async function generateCode(length: number): Promise<string> {
@@ -46,12 +47,33 @@ export class ControllerPlugin extends BaseControllerPlugin {
}, 60e3).unref();
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
let servers: string[] = [];
for (let instance of this.controller.instances.values()) {
if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) {
servers.push(instance.config.get("factorio.settings")["name"] as string || "unnamed server");
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
for (const instance of this.controller.instances.values()) {
const pluginLoaded = instance.config.get("player_auth.load_plugin");
const assignedHost = instance.config.get("instance.assigned_host");
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
continue;
}
const host = this.controller.hosts.get(assignedHost);
if (!host) {
continue;
}
const address = instance.gamePort !== undefined
? `${host.publicAddress}:${instance.gamePort}`
: host.publicAddress;
const settings = instance.config.get("factorio.settings");
const includeAddress = this.controller.config.get("player_auth.show_connect_address");
servers.push({
name: settings["name"] as string || "unnamed server",
factorioVersion: instance.factorioVersion,
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
});
}
res.send(servers);
});
+7
View File
@@ -8,6 +8,7 @@ declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"player_auth.code_length": number;
"player_auth.code_timeout": number;
"player_auth.show_connect_address": boolean;
}
}
@@ -31,6 +32,12 @@ export const plugin: lib.PluginDeclaration = {
type: "number",
initialValue: 120,
},
"player_auth.show_connect_address": {
title: "Show Connect Address",
description: "Show server connect addresses to unauthenticated users.",
type: "boolean",
initialValue: false,
},
},
features: [
+31
View File
@@ -1,5 +1,36 @@
import { Type, Static } from "@sinclair/typebox";
export class PlayerAuthServer {
declare ["constructor"]: typeof PlayerAuthServer;
static jsonSchema = Type.Object({
name: Type.String(),
address: Type.Optional(Type.String()),
factorioVersion: Type.Optional(Type.String()),
});
constructor(
public name: string,
public address?: string,
public factorioVersion?: string,
) {}
toJSON(): Static<typeof PlayerAuthServer.jsonSchema> {
const json = { name: this.name } as Static<typeof PlayerAuthServer.jsonSchema>;
if (this.address !== undefined) {
json.address = this.address;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
return json;
}
static fromJSON(json: Static<typeof PlayerAuthServer.jsonSchema>) {
return new this(json.name, json.address, json.factorioVersion);
}
}
class FetchPlayerCodeResponse {
constructor(
public playerCode: string,
+72 -13
View File
@@ -7,9 +7,11 @@ const mock = require("../../../test/mock");
const controller = require("../dist/node/controller");
const instance = require("../dist/node/instance");
const info = require("../dist/node/index").plugin;
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/node/messages");
const { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } = require("../dist/node/messages");
const { testRoundTripJsonSerialisable, testMatrix } = require("../../../test/common");
const lib = require("@clusterio/lib");
function postJSON(url, body) {
return fetch(url, {
method: "POST",
@@ -19,6 +21,44 @@ function postJSON(url, body) {
}
describe("player_auth", function() {
describe("messages.js", function() {
describe("PlayerAuthServer", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix(
["server1", "server2"], // name
[undefined, "127.0.0.1", "10.0.0.1:1234"], // address
[undefined, "1.1.0", "2.0.0"], // version
));
});
});
describe("FetchPlayerCodeResponse", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix(
["abc123", "player-code"], // playerCode
["http://localhost", "https://example.com"], // controllerUrl
));
});
});
describe("FetchPlayerCodeRequest", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix(
["player1", "player2"], // player
));
});
});
describe("SetVerifyCodeRequest", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix(
["player1", "player2"], // player
["code123", "verify456"], // verifyCode
));
});
});
});
describe("controller.js", function() {
describe("generateCode()", function() {
it("should generate a code of the given length", async function() {
@@ -60,33 +100,52 @@ describe("player_auth", function() {
describe("/api/player_auth/servers", function() {
it("should return a list of running servers with player_auth loaded", async function() {
function addInstance(id, status, load, name) {
function addInstance(id, status, load, name, version, gamePort, host=1) {
controllerPlugin.controller.instances.records.set(id, {
config: {
get(field) {
if (field === "player_auth.load_plugin") {
return load;
} else if (field === "factorio.settings") {
}
if (field === "factorio.settings") {
return { name };
} else if (field === "instance.id") {
}
if (field === "instance.id") {
return id;
}
if (field === "instance.assigned_host") {
return host;
}
throw new Error(`field ${field} not implemented`);
},
},
status,
gamePort,
factorioVersion: version,
});
}
addInstance(1, "running", true, "running loaded");
addInstance(2, "stopped", true, "stopped loaded");
addInstance(3, "running", false, "running unloaded");
addInstance(4, "stopped", false, "stopped unloaded");
addInstance(5, "running", true, undefined);
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197);
addInstance(5, "running", true, undefined, "1.1.5", 34197);
addInstance(6, "running", true, "no port", "1.1.6", undefined);
addInstance(7, "running", true, "no host", "1.1.7", 34197, null);
addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2);
addInstance(9, "running", true, "no version", undefined, 34197);
controllerPlugin.controller.hosts.set(1, {
publicAddress: "127.0.0.1",
});
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]);
for (let id of [1, 2, 3, 4, 5]) {
controllerPlugin.controller.instances.records.delete(id);
}
assert.deepEqual(await result.json(), [
{ name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" },
{ name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" },
{ name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" },
{ name: "no version", address: "127.0.0.1:34197" },
]);
});
});
+149 -48
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui";
import { Button, Form, Input, Spin, Typography } from "antd";
import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd";
import { PlayerAuthServer } from "../messages";
import "./style.css";
const { Paragraph, Text } = Typography;
@@ -12,60 +13,95 @@ interface LoginFormProps {
}
function LoginForm(props: LoginFormProps) {
let [servers, setServers] = useState<string[] | null>(null);
const [servers, setServers] = useState<PlayerAuthServer[] | null>(null);
let [playerCode, setPlayerCode] = useState<string | null>(null);
let [playerCodeError, setPlayerCodeError] = useState<string | null>(null);
let [verifyCode, setVerifyCode] = useState<string | undefined>();
let [verifyToken, setVerifyToken] = useState<string | null>(null);
const [connectServer, setConnectServer] = useState<{ name: string, address: string } | null>(null);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/player_auth/servers`);
if (response.ok) {
setServers(await response.json());
} else {
setServers([]);
let cancelled = false;
async function loadServers() {
try {
const response = await fetch(`${webRoot}api/player_auth/servers`);
if (!response.ok) {
throw new Error(`Bad server response: ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setServers(data);
}
} catch (err) {
if (!cancelled && err instanceof Error) {
setServers([]);
notifyErrorHandler("Error fetching servers")(err);
}
}
})();
}
loadServers();
const interval = setInterval(loadServers, 15 * 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
useEffect(() => {
if (verifyToken) {
let checkLoop = setInterval(() => {
(async () => {
let response = await fetch(`${webRoot}api/player_auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
verify_token: verifyToken,
verify_code: verifyCode,
player_code: playerCode,
}),
});
if (!response.ok) {
throw new Error(`Bad server response: ${response.status}`);
}
let json = await response.json();
if (json.error) {
// While there are other possibilites the most likely
// cause of an error response here is that the code expired.
setPlayerCodeError("Code expired");
setVerifyToken(null);
clearInterval(checkLoop);
return;
}
if (json.verified) {
props.setToken(json.token);
}
})().catch(err => {
clearInterval(checkLoop);
notifyErrorHandler("Error verifying code")(err);
});
}, 2000);
return () => { clearInterval(checkLoop); };
if (!verifyToken) {
return undefined;
}
return undefined;
let cancelled = false;
async function verify() {
try {
const response = await fetch(`${webRoot}api/player_auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
verify_token: verifyToken,
verify_code: verifyCode,
player_code: playerCode,
}),
});
if (!response.ok) {
throw new Error(`Bad server response: ${response.status}`);
}
const json = await response.json();
if (cancelled) {
return;
}
if (json.error) {
setPlayerCodeError("Code expired");
setVerifyToken(null);
clearInterval(interval);
return;
}
if (json.verified) {
props.setToken(json.token);
}
} catch (err) {
if (!cancelled && err instanceof Error) {
clearInterval(interval);
notifyErrorHandler("Error verifying code")(err);
}
}
}
verify();
const interval = setInterval(verify, 2 * 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [verifyToken]);
if (!servers) {
@@ -74,12 +110,47 @@ function LoginForm(props: LoginFormProps) {
if (servers.length === 0) {
return <Paragraph>
There are no servers in the cluster currently running that can complete
the login via Factorio. Please try again later.
There are no running servers available for login via Factorio at the moment.
Please try again later.
</Paragraph>;
}
return <>
<Modal
open={connectServer !== null}
title={`Connect to ${connectServer?.name ?? ""}`}
okText="Connect"
cancelText="Back"
onCancel={() => setConnectServer(null)}
onOk={() => {
if (!connectServer) {
return;
}
window.location.href = `steam://run/427520//--mp-connect=${connectServer.address}`;
setConnectServer(null);
}}
>
<Space direction="vertical" style={{ width: "100%" }}>
<Paragraph>
This button launches Factorio through Steam. It only works if
you own and have installed the Steam version of Factorio, and
Factorio is not already running.
</Paragraph>
<Paragraph>
If you are using the standalone version, or Factorio is already
running, use the following address with <b>Multiplayer → Connect to address</b>:
</Paragraph>
<Typography.Paragraph
copyable={{ text: connectServer?.address ?? "" }}
style={{ marginBottom: 0 }}
>
{connectServer?.address}
</Typography.Paragraph>
</Space>
</Modal>
<Paragraph>Login using your Factorio account is a 3 step process:</Paragraph>
<style>
</style>
@@ -89,9 +160,39 @@ function LoginForm(props: LoginFormProps) {
Start Factorio, join one of the following multiplayer servers
and type <Text keyboard>/web-login</Text> into the chat:
</Paragraph>
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16 }}>
<ul>
{servers.map(text => <li key={text}>{text}</li>)}
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16, padding: "8px 0" }}>
<ul style={{ margin: 0, paddingLeft: 16 }}>
{servers.map(server => (
<li key={server.name} style={{ marginBottom: 6 }}>
<div style={{
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: 8,
}}>
<span>
{server.name}
{server.factorioVersion && (
<Text type="secondary"> ({server.factorioVersion})</Text>
)}
</span>
{server.address && (
<Button
size="small"
onClick={() => {
setConnectServer({
name: server.name,
address: server.address!,
});
}}
>
Connect
</Button>
)}
</div>
</li>
))}
</ul>
</div>
</li>