feat(rcon): unify terminal workspace
This commit is contained in:
@@ -1,14 +1,7 @@
|
||||
import { rconServers } from "@minecraft-account-manager/database";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { RconConsole } from "@/components/rcon-console";
|
||||
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||
import { db } from "@/lib/database";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
updateRconServer,
|
||||
} from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -46,13 +39,17 @@ export default async function RconPage({
|
||||
const query = await searchParams;
|
||||
const saved = queryValue(query.saved);
|
||||
const error = queryValue(query.error);
|
||||
const notice: RconTerminalNotice | undefined = error
|
||||
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
|
||||
: saved
|
||||
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
|
||||
: undefined;
|
||||
const servers = await db.select({
|
||||
id: rconServers.id,
|
||||
name: rconServers.name,
|
||||
host: rconServers.host,
|
||||
port: rconServers.port,
|
||||
enabled: rconServers.enabled,
|
||||
updatedAt: rconServers.updatedAt,
|
||||
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||
|
||||
return (
|
||||
@@ -60,88 +57,19 @@ export default async function RconPage({
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Run commands through the portal backend to internal or external server addresses. Credentials are never sent to the browser.</p>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||
</header>
|
||||
|
||||
{saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[saved] ?? "RCON settings saved."}</p>}
|
||||
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The RCON operation failed."}</p>}
|
||||
|
||||
<div className="mt-10 grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Console</h2>
|
||||
<p className="mt-3 text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
<RconConsole servers={servers.filter((server) => server.enabled).map(({ id, name }) => ({ id, name }))} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Configuration</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Add connection</h2>
|
||||
<form action={createRconServer} className="mt-5 space-y-4 border border-line bg-panel p-6">
|
||||
<ConnectionFields prefix="new" />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" name="enabled" type="checkbox" value="yes" />Enable immediately</label>
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add connection</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Saved endpoints</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Connections</h2></div>
|
||||
<span className="font-mono text-xs text-muted">{servers.length} configured</span>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{servers.map((server) => (
|
||||
<article className="grid gap-5 py-6 lg:grid-cols-[1fr_auto] lg:items-start" key={server.id}>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3"><h3 className="font-mono text-lg font-bold">{server.name}</h3><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${server.enabled ? "bg-signal text-ink" : "border border-line text-muted"}`}>{server.enabled ? "Enabled" : "Disabled"}</span></div>
|
||||
<p className="mt-2 font-mono text-[10px] text-muted">{server.host}:{server.port}</p>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">Updated {server.updatedAt.toISOString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<form action={testSavedRconServer}><input name="serverId" type="hidden" value={server.id} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">Test</button></form>
|
||||
<form action={setRconServerEnabled}><input name="serverId" type="hidden" value={server.id} /><input name="enabled" type="hidden" value={server.enabled ? "no" : "yes"} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">{server.enabled ? "Disable" : "Enable"}</button></form>
|
||||
<details className="relative">
|
||||
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase underline underline-offset-4">Edit</summary>
|
||||
<form action={updateRconServer} className="relative z-10 mt-3 w-[min(28rem,80vw)] space-y-4 border border-line bg-panel p-5 shadow-[5px_5px_0_var(--color-shadow)] lg:absolute lg:right-0">
|
||||
<input name="serverId" type="hidden" value={server.id} />
|
||||
<ConnectionFields defaults={server} prefix={server.id} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" defaultChecked={server.enabled} name="enabled" type="checkbox" value="yes" />Enabled</label>
|
||||
<button className="border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase" type="submit">Save connection</button>
|
||||
</form>
|
||||
</details>
|
||||
<details className="relative">
|
||||
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4">Delete</summary>
|
||||
<form action={deleteRconServer} className="relative z-10 mt-3 w-64 border border-accent bg-panel p-5 shadow-[5px_5px_0_var(--color-accent)] lg:absolute lg:right-0">
|
||||
<input name="serverId" type="hidden" value={server.id} /><input name="confirmation" type="hidden" value={server.id} />
|
||||
<p className="text-xs leading-5">Delete {server.name}? Its encrypted credential will be removed.</p>
|
||||
<button className="mt-4 bg-accent px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm deletion</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!servers.length && <p className="py-8 text-sm text-muted">No RCON connections configured.</p>}
|
||||
<section className="mt-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
|
||||
</div>
|
||||
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
</div>
|
||||
<RconConsole notice={notice} servers={servers} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
prefix,
|
||||
defaults,
|
||||
}: {
|
||||
prefix: string;
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,58 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||
createRconServer: vi.fn(),
|
||||
deleteRconServer: vi.fn(),
|
||||
executeRconCommand: vi.fn(),
|
||||
setRconServerEnabled: vi.fn(),
|
||||
testSavedRconServer: vi.fn(),
|
||||
updateRconServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RconConsole } from "./rcon-console";
|
||||
|
||||
const server = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("RconConsole", () => {
|
||||
it("renders a labelled keyboard-operable terminal without persisted history", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[{ id: "one", name: "Season 4" }]} />);
|
||||
it("renders one wide terminal workspace with connection controls and modal forms", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain('for="rcon-console-server"');
|
||||
expect(markup).toContain('for="rcon-command"');
|
||||
expect(markup).toContain("w-full");
|
||||
expect(markup).toContain("Season 4");
|
||||
expect(markup).toContain("server://");
|
||||
expect(markup).toContain("Awaiting command");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).toContain("Edit");
|
||||
expect(markup).toContain("Test");
|
||||
expect(markup).toContain("Disable");
|
||||
expect(markup).toContain("Delete");
|
||||
expect(markup).toContain("Add RCON connection");
|
||||
expect(markup).toContain("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
it("explains when no enabled connection is available", () => {
|
||||
expect(renderToStaticMarkup(<RconConsole servers={[]} />)).toContain("Enable an RCON connection");
|
||||
it("renders connection operation notices inside the terminal viewport", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||
expect(markup).toContain("RCON authentication timed out.");
|
||||
expect(markup).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("keeps the terminal and add action available when no connection exists", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain("No connections configured");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).not.toContain("Edit");
|
||||
expect(markup).not.toContain("Delete");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,56 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
|
||||
import { useActionState, useState } from "react";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
type RconCommandState,
|
||||
updateRconServer,
|
||||
} from "@/app/admin/(console)/rcon/actions";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
|
||||
type ServerOption = { id: string; name: string };
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function RconConsole({ servers }: { servers: ServerOption[] }) {
|
||||
export type RconTerminalNotice = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({
|
||||
notice,
|
||||
servers,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
servers: RconServerOption[];
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
const responseServer = servers.find((server) => server.id === state.serverId);
|
||||
|
||||
if (!servers.length) {
|
||||
return <p className="mt-5 text-sm text-muted">Enable an RCON connection before opening the console.</p>;
|
||||
}
|
||||
const output = terminalOutput({ notice, pending, responseServer, selected, state });
|
||||
|
||||
return (
|
||||
<form action={action} aria-label="RCON terminal" className="mt-6 overflow-hidden border-2 border-ink bg-panel shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line bg-canvas px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className="size-2 rounded-full bg-signal shadow-[0_0_0_1px_var(--color-ink)]" />
|
||||
<span>server://</span>
|
||||
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||
<span>server://</span>
|
||||
</div>
|
||||
{servers.length ? (
|
||||
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
<span className="sr-only">Server</span>
|
||||
<select
|
||||
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
|
||||
id="rcon-console-server"
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
value={selected?.id}
|
||||
>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||
)}
|
||||
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ConnectionModal mode="add" />
|
||||
{selected && (
|
||||
<>
|
||||
<form action={testSavedRconServer}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<HeaderButton label="Test" />
|
||||
</form>
|
||||
<form action={setRconServerEnabled}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||
</form>
|
||||
<ConnectionModal mode="edit" server={selected} />
|
||||
<AdminModalForm
|
||||
action={deleteRconServer}
|
||||
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||
intent="danger"
|
||||
submitLabel="Delete connection"
|
||||
title={`Delete ${selected.name}?`}
|
||||
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||
triggerLabel="Delete"
|
||||
>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="confirmation" type="hidden" value={selected.id} />
|
||||
</AdminModalForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
Target
|
||||
<select className="border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent" defaultValue={state.serverId || servers[0]?.id} id="rcon-console-server" name="serverId" required>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" aria-relevant="additions text" className="min-h-52 max-h-80 overflow-auto p-5 font-mono text-xs leading-5" role={state.status === "error" ? "alert" : "status"}>
|
||||
{pending ? (
|
||||
<p className="text-muted"><span aria-hidden="true" className="mr-2 text-accent">›</span>Executing command…</p>
|
||||
) : state.status === "idle" ? (
|
||||
<p className="text-muted"><span aria-hidden="true" className="mr-2 text-accent">#</span>Awaiting command</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${state.status === "error" ? "text-accent" : "text-muted"}`}>
|
||||
{state.status === "error" ? "Error" : "Response"}{responseServer ? ` — ${responseServer.name}` : ""}
|
||||
</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{state.message}</pre>
|
||||
</>
|
||||
)}
|
||||
<div aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" role={output.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${output.status === "error" ? "text-accent" : "text-muted"}`}>{output.label}</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3">
|
||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3">
|
||||
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
|
||||
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
|
||||
<label className="sr-only" htmlFor="rcon-command">Command</label>
|
||||
<input autoComplete="off" autoFocus className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none" id="rcon-command" maxLength={1024} name="command" placeholder="list" required spellCheck={false} />
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
<input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selected?.enabled || pending}
|
||||
id="rcon-command"
|
||||
key={selected?.id ?? "no-server"}
|
||||
maxLength={1024}
|
||||
name="command"
|
||||
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||
required
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function terminalOutput({
|
||||
notice,
|
||||
pending,
|
||||
responseServer,
|
||||
selected,
|
||||
state,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
pending: boolean;
|
||||
responseServer?: RconServerOption;
|
||||
selected?: RconServerOption;
|
||||
state: RconCommandState;
|
||||
}) {
|
||||
if (pending) return { status: "success" as const, label: "Executing", message: "Command in progress…" };
|
||||
if (state.status !== "idle") return {
|
||||
status: state.status,
|
||||
label: `${state.status === "error" ? "Error" : "Response"}${responseServer ? ` — ${responseServer.name}` : ""}`,
|
||||
message: state.message,
|
||||
};
|
||||
if (notice) return { ...notice, label: notice.status === "error" ? "Connection error" : "Connection update" };
|
||||
if (!selected) return { status: "success" as const, label: "Ready", message: "No connections configured. Use Add to create a server connection." };
|
||||
if (!selected.enabled) return { status: "error" as const, label: `Disabled — ${selected.name}`, message: "Enable this connection before testing commands." };
|
||||
return { status: "success" as const, label: `Ready — ${selected.name}`, message: "Awaiting command" };
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||
}
|
||||
|
||||
function ConnectionModal({
|
||||
mode,
|
||||
server,
|
||||
}: {
|
||||
mode: "add" | "edit";
|
||||
server?: RconServerOption;
|
||||
}) {
|
||||
const editing = mode === "edit" ? server : undefined;
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={editing ? updateRconServer : createRconServer}
|
||||
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||
triggerLabel={editing ? "Edit" : "Add"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||
{editing ? "Enabled" : "Enable immediately"}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
defaults,
|
||||
prefix,
|
||||
}: {
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
prefix: string;
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* **Refine**: Removed deployment-managed RCON endpoint allowlisting so administrators may configure any valid DNS hostname and port, while retaining IP-literal rejection and documenting the outbound-connectivity trust boundary.
|
||||
* **Verify**: Confirmed the RCON console uses an authenticated internal ClusterIP deployment with secret-backed credentials and no public RCON exposure.
|
||||
* **Refine**: Renamed RCON host configuration to server addresses, documented internal and external targets, and redesigned the console as a portal-colored terminal with a target bar, command prompt, and latest-response viewport.
|
||||
* **Refine**: Consolidated RCON connection management into a full-width terminal workspace with header controls, modal add/edit/delete flows, terminal-contained notices, and no duplicate configuration panels.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Manage RCON server connections
|
||||
description: Administrators manage encrypted connection settings for Minecraft RCON server addresses.
|
||||
tags: [admin, rcon, minecraft, security, operations]
|
||||
timestamp: 2026-08-08T12:05:37Z
|
||||
timestamp: 2026-08-08T13:40:43Z
|
||||
story_id: US-021
|
||||
status: verified
|
||||
---
|
||||
@@ -14,19 +14,19 @@ As an administrator, I want to manage one or more Minecraft RCON connections, so
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Existing account-manager administrators can list, add, edit, test, enable or disable, and delete RCON server connections.
|
||||
- [x] Existing account-manager administrators use the terminal header to select, add, edit, test, enable or disable, and delete RCON server connections.
|
||||
- [x] Each connection has a unique display name, server address, port, enabled state, and write-only password.
|
||||
- [x] RCON passwords are encrypted with an authenticated cipher using a deployment-managed master key and are never returned to the browser, audit events, or application logs.
|
||||
- [x] Updating a connection preserves its password unless an administrator explicitly supplies a replacement.
|
||||
- [x] Administrators can save any syntactically valid DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected.
|
||||
- [x] Testing a connection authenticates through the server-side RCON proxy and reports a safe success or failure result.
|
||||
- [x] Deleting a connection requires explicit confirmation.
|
||||
- [x] Add and edit use accessible modal forms, and deleting a connection requires an explicit danger-confirmation modal.
|
||||
- [x] Connection mutations independently recheck administrator authorization and create credential-safe audit events.
|
||||
- [x] Database changes use a generated versioned Drizzle migration rather than schema push.
|
||||
|
||||
# Implementation
|
||||
|
||||
The administrator RCON page and server actions manage endpoints without deployment-managed endpoint configuration, preserve write-only passwords, encrypt credentials with connection-bound AES-256-GCM, and emit credential-safe audit events. The `rcon_servers` table is delivered through generated migration `0006_curious_lester.sql`.
|
||||
The unified terminal header selects connections and exposes add, test, enable or disable, edit, and delete controls. Add and edit use reusable accessible modal forms, while delete uses a danger-confirmation modal. Server actions manage endpoints without deployment-managed endpoint configuration, preserve write-only passwords, encrypt credentials with connection-bound AES-256-GCM, and emit credential-safe audit events. The `rcon_servers` table is delivered through generated migration `0006_curious_lester.sql`.
|
||||
|
||||
# Validation
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Operate servers through an RCON console
|
||||
description: Administrators execute bounded RCON commands through the server-side portal proxy.
|
||||
tags: [admin, rcon, minecraft, console, security]
|
||||
timestamp: 2026-08-08T12:05:37Z
|
||||
timestamp: 2026-08-08T13:40:43Z
|
||||
story_id: US-022
|
||||
status: verified
|
||||
---
|
||||
@@ -21,16 +21,18 @@ As an administrator, I want an RCON console in the portal, so that I can operate
|
||||
- [x] Command responses are displayed safely and are not persisted in console history, audit data, or application logs.
|
||||
- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses.
|
||||
- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
|
||||
- [x] The console uses the portal color palette to present a terminal-style server header, single keyboard-accessible prompt, pending state, and scrollable latest-response viewport.
|
||||
- [x] The console spans the available content width and uses the portal color palette to present a terminal-style server header with connection controls, a single keyboard-accessible prompt, pending state, and scrollable latest-response viewport.
|
||||
- [x] Configured server addresses may be internal or external, and operators receive guidance that RCON network exposure and transport security remain their responsibility.
|
||||
- [x] Command responses, connection errors, and connection-operation results appear in the terminal viewport, including an actionable empty state when no connection exists.
|
||||
- [x] The page has no duplicate connection form or connection-list panel outside the terminal workspace.
|
||||
|
||||
# Implementation
|
||||
|
||||
The portal-colored terminal interface identifies the selected server in its header, accepts one command through a keyboard-focused prompt, and displays only the latest bounded response in a scrollable viewport. The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
|
||||
The full-width portal-colored terminal workspace identifies and manages the selected server in its header, accepts one command through a keyboard-focused prompt, and displays command responses plus connection-operation notices in one scrollable viewport. It retains an actionable terminal and Add control when no connections exist, with no duplicate configuration panels. The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
|
||||
|
||||
# Validation
|
||||
|
||||
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Component validation confirms labelled server and command controls, terminal semantics, an idle output viewport, and an accessible no-server state. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
||||
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Component validation confirms the full-width workspace, labelled server and command controls, header actions, accessible modal forms, terminal-contained notices, and the actionable no-server state. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
Reference in New Issue
Block a user