Protect launcher admin tools

This commit is contained in:
Andraxion 2026-06-27 00:51:20 -04:00
parent fad065c429
commit ab1dfbf029
4 changed files with 384 additions and 151 deletions

View file

@ -17,6 +17,7 @@ const DEFAULTS = {
modelBaseUrl: process.env.REQUEST_ANALYZER_MODEL_BASE_URL || "", modelBaseUrl: process.env.REQUEST_ANALYZER_MODEL_BASE_URL || "",
model: process.env.REQUEST_ANALYZER_MODEL || "", model: process.env.REQUEST_ANALYZER_MODEL || "",
apiKey: process.env.REQUEST_ANALYZER_API_KEY || process.env.DEEPSEEK_API_KEY || "", apiKey: process.env.REQUEST_ANALYZER_API_KEY || process.env.DEEPSEEK_API_KEY || "",
adminPassword: process.env.REQUEST_ANALYZER_ADMIN_PASSWORD || process.env.LAUNCHER_ADMIN_PASSWORD || "",
limit: Number(process.env.REQUEST_ANALYZER_LIMIT || 5), limit: Number(process.env.REQUEST_ANALYZER_LIMIT || 5),
promoteThreshold: Number(process.env.REQUEST_ANALYZER_PROMOTE_THRESHOLD || 0.85), promoteThreshold: Number(process.env.REQUEST_ANALYZER_PROMOTE_THRESHOLD || 0.85),
maxTokens: Math.max(512, Number(process.env.REQUEST_ANALYZER_MAX_TOKENS || 4000)), maxTokens: Math.max(512, Number(process.env.REQUEST_ANALYZER_MAX_TOKENS || 4000)),
@ -57,12 +58,14 @@ Environment variables:
REQUEST_ANALYZER_MODEL_BASE_URL REQUEST_ANALYZER_MODEL_BASE_URL
REQUEST_ANALYZER_MODEL REQUEST_ANALYZER_MODEL
REQUEST_ANALYZER_API_KEY REQUEST_ANALYZER_API_KEY
REQUEST_ANALYZER_ADMIN_PASSWORD
REQUEST_ANALYZER_MAX_TOKENS REQUEST_ANALYZER_MAX_TOKENS
REQUEST_ANALYZER_THINKING REQUEST_ANALYZER_THINKING
REQUEST_ANALYZER_LIMIT REQUEST_ANALYZER_LIMIT
REQUEST_ANALYZER_INTERVAL_MS REQUEST_ANALYZER_INTERVAL_MS
REQUEST_ANALYZER_PROMOTE_THRESHOLD REQUEST_ANALYZER_PROMOTE_THRESHOLD
DEEPSEEK_API_KEY DEEPSEEK_API_KEY
LAUNCHER_ADMIN_PASSWORD
Notes: Notes:
- DeepSeek uses ${DEFAULT_DEEPSEEK_BASE_URL}/chat/completions. - DeepSeek uses ${DEFAULT_DEEPSEEK_BASE_URL}/chat/completions.
@ -194,6 +197,17 @@ async function fetchJson(url, init = {}) {
return response.json(); return response.json();
} }
function buildAdminHeaders(config, baseHeaders = {}) {
const nextHeaders = {
...baseHeaders,
};
const adminPassword = String(config?.adminPassword || "").trim();
if (adminPassword) {
nextHeaders["x-worldshaper-admin-password"] = adminPassword;
}
return nextHeaders;
}
function tokenize(value) { function tokenize(value) {
return String(value || "") return String(value || "")
.toLowerCase() .toLowerCase()
@ -498,9 +512,9 @@ async function getLauncherRequests(config) {
async function patchLauncherRequest(config, requestId, body) { async function patchLauncherRequest(config, requestId, body) {
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}`), { return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}`), {
method: "PATCH", method: "PATCH",
headers: { headers: buildAdminHeaders(config, {
"Content-Type": "application/json", "Content-Type": "application/json",
}, }),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }
@ -508,9 +522,9 @@ async function patchLauncherRequest(config, requestId, body) {
async function processLauncherRequestAnalysis(config, requestId, body) { async function processLauncherRequestAnalysis(config, requestId, body) {
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}/process-analysis`), { return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}/process-analysis`), {
method: "POST", method: "POST",
headers: { headers: buildAdminHeaders(config, {
"Content-Type": "application/json", "Content-Type": "application/json",
}, }),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }

View file

@ -10,6 +10,38 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const port = Number(process.env.PORT) || 5180; const port = Number(process.env.PORT) || 5180;
const host = process.env.HOST || "0.0.0.0"; const host = process.env.HOST || "0.0.0.0";
const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim();
function isLauncherAdminProtectionEnabled() {
return Boolean(launcherAdminPassword);
}
function readLauncherAdminPasswordCandidate(req) {
const headerValue = req.get("x-worldshaper-admin-password");
if (String(headerValue || "").trim()) {
return String(headerValue || "").trim();
}
return String(req.body?.password || "").trim();
}
function requireLauncherAdminAccess(req, res) {
if (!isLauncherAdminProtectionEnabled()) {
res.status(503).json({
error: "Launcher admin access is not configured on the server.",
adminConfigured: false,
});
return false;
}
const submittedPassword = readLauncherAdminPasswordCandidate(req);
if (!submittedPassword || submittedPassword !== launcherAdminPassword) {
res.status(401).json({
error: "Admin access denied.",
adminConfigured: true,
});
return false;
}
return true;
}
function resolveContentRoot() { function resolveContentRoot() {
const envPath = String(process.env.CONTENT_ROOT || "").trim(); const envPath = String(process.env.CONTENT_ROOT || "").trim();
@ -2610,6 +2642,9 @@ app.get("/api/types", (_req, res) => {
}); });
app.get("/api/debug/paths", (_req, res) => { app.get("/api/debug/paths", (_req, res) => {
if (!requireLauncherAdminAccess(_req, res)) {
return;
}
const contentFiles = Object.fromEntries( const contentFiles = Object.fromEntries(
Object.entries(contentMap).map(([type, entry]) => { Object.entries(contentMap).map(([type, entry]) => {
const fullPath = path.join(contentRoot, entry.file); const fullPath = path.join(contentRoot, entry.file);
@ -2637,7 +2672,37 @@ app.get("/api/debug/paths", (_req, res) => {
}); });
}); });
app.get("/api/debug/recent-saves", (_req, res) => { app.post("/api/admin/auth-check", (req, res) => {
if (!isLauncherAdminProtectionEnabled()) {
res.status(503).json({
ok: false,
accessGranted: false,
adminConfigured: false,
error: "Launcher admin access is not configured on the server.",
});
return;
}
const accessGranted = readLauncherAdminPasswordCandidate(req) === launcherAdminPassword;
if (!accessGranted) {
res.status(401).json({
ok: false,
accessGranted: false,
adminConfigured: true,
error: "Admin access denied.",
});
return;
}
res.json({
ok: true,
accessGranted: true,
adminConfigured: true,
});
});
app.get("/api/debug/recent-saves", (req, res) => {
if (!requireLauncherAdminAccess(req, res)) {
return;
}
res.json({ res.json({
ok: true, ok: true,
contentRoot, contentRoot,
@ -2701,6 +2766,9 @@ app.post("/api/launcher-requests", (req, res) => {
app.patch("/api/launcher-requests/:requestId", (req, res) => { app.patch("/api/launcher-requests/:requestId", (req, res) => {
const requestId = String(req.params.requestId || "").trim(); const requestId = String(req.params.requestId || "").trim();
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try { try {
const payload = readLauncherRequestsPayload(); const payload = readLauncherRequestsPayload();
const index = payload.requests.findIndex((entry) => entry.id === requestId); const index = payload.requests.findIndex((entry) => entry.id === requestId);
@ -2745,6 +2813,9 @@ app.patch("/api/launcher-requests/:requestId", (req, res) => {
app.delete("/api/launcher-requests/:requestId", (req, res) => { app.delete("/api/launcher-requests/:requestId", (req, res) => {
const requestId = String(req.params.requestId || "").trim(); const requestId = String(req.params.requestId || "").trim();
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try { try {
const payload = readLauncherRequestsPayload(); const payload = readLauncherRequestsPayload();
const existing = payload.requests.find((entry) => entry.id === requestId); const existing = payload.requests.find((entry) => entry.id === requestId);
@ -2775,6 +2846,9 @@ app.delete("/api/launcher-requests/:requestId", (req, res) => {
app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => { app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => {
const requestId = String(req.params.requestId || "").trim(); const requestId = String(req.params.requestId || "").trim();
const action = String(req.body?.action || "").trim().toLowerCase(); const action = String(req.body?.action || "").trim().toLowerCase();
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try { try {
const payload = readLauncherRequestsPayload(); const payload = readLauncherRequestsPayload();
const index = payload.requests.findIndex((entry) => entry.id === requestId); const index = payload.requests.findIndex((entry) => entry.id === requestId);
@ -2866,7 +2940,10 @@ app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => {
} }
}); });
app.post("/api/launcher-requests/process-pending", (_req, res) => { app.post("/api/launcher-requests/process-pending", (req, res) => {
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try { try {
const result = launchQueuedRequestAnalysis("manual-api-trigger"); const result = launchQueuedRequestAnalysis("manual-api-trigger");
res.json({ res.json({

View file

@ -95,6 +95,13 @@ type ProcessPendingPayload = {
pid?: number; pid?: number;
}; };
type AdminAuthPayload = {
ok?: boolean;
accessGranted?: boolean;
adminConfigured?: boolean;
error?: string;
};
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
async function resolveDefaultWorldId(): Promise<string> { async function resolveDefaultWorldId(): Promise<string> {
@ -122,6 +129,25 @@ async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit)
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit {
const normalizedPassword = String(password || "").trim();
if (!normalizedPassword) {
return {
...(headers || {}),
};
}
return {
...(headers || {}),
"x-worldshaper-admin-password": normalizedPassword,
};
}
function isAdminAccessError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("admin access denied")
|| text.includes("admin access is not configured");
}
function formatRequestTimestamp(value: string): string { function formatRequestTimestamp(value: string): string {
const parsed = Date.parse(String(value || "")); const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
@ -281,6 +307,11 @@ function WorldshaperLauncher() {
const [requestFilter, setRequestFilter] = useState("all"); const [requestFilter, setRequestFilter] = useState("all");
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]); const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
const [adminPanelOpen, setAdminPanelOpen] = useState(false); const [adminPanelOpen, setAdminPanelOpen] = useState(false);
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
const [adminPassword, setAdminPassword] = useState("");
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
const [adminPasswordError, setAdminPasswordError] = useState("");
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]); const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
const [logsLoading, setLogsLoading] = useState(false); const [logsLoading, setLogsLoading] = useState(false);
const [logsError, setLogsError] = useState(""); const [logsError, setLogsError] = useState("");
@ -329,19 +360,37 @@ function WorldshaperLauncher() {
async function loadRecentSaveEvents(): Promise<void> { async function loadRecentSaveEvents(): Promise<void> {
setLogsLoading(true); setLogsLoading(true);
try { try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves"); const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
headers: buildAdminHeaders(adminPassword),
});
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
setLogsError(""); setLogsError("");
} catch (nextError: unknown) { } catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
}
setLogsError(String(nextError || "Failed to load admin logs.")); setLogsError(String(nextError || "Failed to load admin logs."));
} finally { } finally {
setLogsLoading(false); setLogsLoading(false);
} }
} }
async function verifyAdminPassword(password: string): Promise<void> {
const payload = await fetchJsonOrThrow<AdminAuthPayload>("/api/admin/auth-check", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
if (!payload.accessGranted) {
throw new Error(String(payload.error || "Admin access denied."));
}
}
async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> { async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> {
await loadRequests({ silent: options?.silentRequests === true }); await loadRequests({ silent: options?.silentRequests === true });
if (options?.includeLogs) { if (options?.includeLogs && adminAccessGranted && adminPassword) {
await loadRecentSaveEvents(); await loadRecentSaveEvents();
} }
} }
@ -351,11 +400,11 @@ function WorldshaperLauncher() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!adminPanelOpen) { if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return; return;
} }
void loadRecentSaveEvents(); void loadRecentSaveEvents();
}, [adminPanelOpen]); }, [adminPanelOpen, adminAccessGranted, adminPassword]);
useEffect(() => { useEffect(() => {
if (activeBoardTab !== "requests") { if (activeBoardTab !== "requests") {
@ -371,11 +420,13 @@ function WorldshaperLauncher() {
} catch { } catch {
// Keep the current list visible during background refresh failures. // Keep the current list visible during background refresh failures.
} }
if (!adminPanelOpen) { if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return; return;
} }
try { try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves"); const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
headers: buildAdminHeaders(adminPassword),
});
if (!cancelled) { if (!cancelled) {
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
} }
@ -390,7 +441,7 @@ function WorldshaperLauncher() {
cancelled = true; cancelled = true;
window.clearInterval(intervalId); window.clearInterval(intervalId);
}; };
}, [activeBoardTab, adminPanelOpen]); }, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
async function handleLaunch(): Promise<void> { async function handleLaunch(): Promise<void> {
setError(""); setError("");
@ -436,11 +487,11 @@ function WorldshaperLauncher() {
setRequestDraftOpen(false); setRequestDraftOpen(false);
setRequestsError(""); setRequestsError("");
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled."); setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
if (adminPanelOpen) { if (adminPanelOpen && adminAccessGranted) {
void loadRecentSaveEvents(); void loadRecentSaveEvents();
} }
window.setTimeout(() => { window.setTimeout(() => {
void refreshAdminData({ includeLogs: adminPanelOpen, silentRequests: true }); void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true });
}, 3500); }, 3500);
} catch (nextError: unknown) { } catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to save request.")); setRequestsError(String(nextError || "Failed to save request."));
@ -449,6 +500,45 @@ function WorldshaperLauncher() {
} }
} }
async function handleAdminPanelToggle(): Promise<void> {
setRequestDraftOpen(false);
setRequestsError("");
setLogsError("");
if (adminPanelOpen) {
setAdminPanelOpen(false);
setAdminNotice("");
return;
}
setAdminPanelOpen(true);
setAdminPasswordError("");
if (adminAccessGranted && adminPassword) {
await refreshAdminData({ includeLogs: true, silentRequests: true });
}
}
async function handleAdminUnlock(): Promise<void> {
const submittedPassword = adminPasswordDraft.trim();
if (!submittedPassword) {
setAdminPasswordError("Enter the admin password to continue.");
return;
}
setAdminAuthSubmitting(true);
setAdminPasswordError("");
try {
await verifyAdminPassword(submittedPassword);
setAdminPassword(submittedPassword);
setAdminAccessGranted(true);
setAdminNotice("Admin access granted.");
await refreshAdminData({ includeLogs: true, silentRequests: true });
} catch (nextError: unknown) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError(String(nextError || "Failed to unlock the admin panel."));
} finally {
setAdminAuthSubmitting(false);
}
}
function handleToggleExpandedRequest(requestId: string): void { function handleToggleExpandedRequest(requestId: string): void {
setExpandedRequestIds((current) => ( setExpandedRequestIds((current) => (
current.includes(requestId) current.includes(requestId)
@ -466,6 +556,7 @@ function WorldshaperLauncher() {
try { try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, { const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
method: "DELETE", method: "DELETE",
headers: buildAdminHeaders(adminPassword),
}); });
setRequests(Array.isArray(payload.requests) ? payload.requests : []); setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError(""); setRequestsError("");
@ -475,6 +566,11 @@ function WorldshaperLauncher() {
void loadRecentSaveEvents(); void loadRecentSaveEvents();
} }
} catch (nextError: unknown) { } catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setRequestsError(String(nextError || "Failed to delete request.")); setRequestsError(String(nextError || "Failed to delete request."));
} finally { } finally {
setRequestMutatingId(""); setRequestMutatingId("");
@ -486,6 +582,7 @@ function WorldshaperLauncher() {
try { try {
const payload = await fetchJsonOrThrow<ProcessPendingPayload>("/api/launcher-requests/process-pending", { const payload = await fetchJsonOrThrow<ProcessPendingPayload>("/api/launcher-requests/process-pending", {
method: "POST", method: "POST",
headers: buildAdminHeaders(adminPassword),
}); });
if (payload.launched) { if (payload.launched) {
setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`); setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`);
@ -508,6 +605,11 @@ function WorldshaperLauncher() {
}, 4200); }, 4200);
} }
} catch (nextError: unknown) { } catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setLogsError(String(nextError || "Failed to trigger the queue worker.")); setLogsError(String(nextError || "Failed to trigger the queue worker."));
} finally { } finally {
setQueueTriggering(false); setQueueTriggering(false);
@ -660,144 +762,185 @@ function WorldshaperLauncher() {
<div className="changelog-splash-meta"> <div className="changelog-splash-meta">
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active. {requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
</div> </div>
</div> <div className="launcher-request-hero-actions">
<div className="launcher-request-controls">
<div className="launcher-request-toolbar">
<div className="launcher-request-toolbar-buttons"> <div className="launcher-request-toolbar-buttons">
<button <button
type="button" type="button"
className="launcher-primary-btn" className="launcher-primary-btn"
onClick={() => setRequestDraftOpen((value) => !value)} onClick={() => {
setAdminPanelOpen(false);
setRequestDraftOpen((value) => !value);
}}
disabled={requestSubmitting} disabled={requestSubmitting}
> >
{requestDraftOpen ? "Hide Request Form" : "Add New Request"} {requestDraftOpen && !adminPanelOpen ? "Hide Request Form" : "Add New Request"}
</button> </button>
<button <button
type="button" type="button"
className={`launcher-secondary-btn ${adminPanelOpen ? "is-active" : ""}`} className={`launcher-secondary-btn ${adminPanelOpen ? "is-active" : ""}`}
onClick={() => setAdminPanelOpen((value) => !value)} onClick={() => void handleAdminPanelToggle()}
> >
{adminPanelOpen ? "Hide Admin Panel" : "Admin Panel"} {adminPanelOpen ? "Hide Admin Panel" : "Admin Panel"}
</button> </button>
</div> </div>
<label className="launcher-request-filter">
<span className="launcher-request-filter-label">Filter</span>
<select
className="launcher-request-filter-select"
value={requestFilter}
onChange={(event) => setRequestFilter(event.target.value)}
>
<option value="all">All Requests</option>
<option value="status:pending">Pending Requests</option>
<option value="status:queued">Queued For Analysis</option>
<option value="status:review">Needs Review</option>
<option value="status:active">Active Requests</option>
{requestTags.map((tag) => (
<option key={tag} value={`tag:${tag}`}>{tag}</option>
))}
</select>
</label>
</div> </div>
<label className="launcher-request-filter">
<span className="launcher-request-filter-label">Filter</span>
<select
className="launcher-request-filter-select"
value={requestFilter}
onChange={(event) => setRequestFilter(event.target.value)}
>
<option value="all">All Requests</option>
<option value="status:pending">Pending Requests</option>
<option value="status:queued">Queued For Analysis</option>
<option value="status:review">Needs Review</option>
<option value="status:active">Active Requests</option>
{requestTags.map((tag) => (
<option key={tag} value={`tag:${tag}`}>{tag}</option>
))}
</select>
</label>
</div> </div>
{adminPanelOpen ? ( {adminPanelOpen ? (
<section className="launcher-request-admin-panel"> <section className="launcher-request-admin-panel">
<div className="launcher-request-admin-head"> {!adminAccessGranted ? (
<div> <div className="launcher-request-admin-unlock">
<div className="launcher-request-admin-kicker">Moderation Tools</div> <div className="launcher-request-admin-kicker">Protected Tools</div>
<h3 className="launcher-request-admin-title">Admin Panel</h3> <h3 className="launcher-request-admin-title">Admin Access Required</h3>
<p className="launcher-request-admin-copy"> <p className="launcher-request-admin-copy">
Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place. Enter the admin password to manage deletions, run the queue worker, and read request logs.
</p> </p>
</div> <label className="launcher-request-admin-field">
<div className="launcher-request-admin-stats"> <span className="launcher-request-filter-label">Password</span>
<span>{queuedPendingRequestCount} queued</span> <input
<span>{needsReviewRequestCount} review</span> type="password"
<span>{pendingRequestCount} pending</span> className="launcher-request-filter-select"
<span>{activeRequestCount} active</span> value={adminPasswordDraft}
</div> onChange={(event) => setAdminPasswordDraft(event.target.value)}
</div> onKeyDown={(event) => {
<div className="launcher-request-admin-actions"> if (event.key === "Enter") {
<button event.preventDefault();
type="button" void handleAdminUnlock();
className="launcher-primary-btn" }
onClick={() => void handleProcessPendingQueue()} }}
disabled={queueTriggering} placeholder="Enter admin password"
> />
{queueTriggering ? "Starting Queue..." : "Run Pending Queue"} </label>
</button> <div className="launcher-request-admin-actions">
<button <button
type="button" type="button"
className="launcher-secondary-btn" className="launcher-primary-btn"
onClick={() => void refreshAdminData({ includeLogs: true, silentRequests: true })} onClick={() => void handleAdminUnlock()}
disabled={logsLoading} disabled={adminAuthSubmitting}
> >
{logsLoading ? "Refreshing..." : "Refresh Admin Data"} {adminAuthSubmitting ? "Unlocking..." : "Unlock Admin Panel"}
</button> </button>
</div>
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null}
{logsError ? <p className="launcher-request-error">{logsError}</p> : null}
<div className="launcher-request-admin-grid">
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Request Management</h4>
<div className="launcher-request-admin-card-hint">Delete actions live here now.</div>
</div> </div>
<div className="launcher-request-admin-request-list"> {adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
{requests.map((requestEntry) => { </div>
const isMutating = requestMutatingId === requestEntry.id; ) : (
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state); <>
return ( <div className="launcher-request-admin-head">
<article key={`admin-${requestEntry.id}`} className="launcher-request-admin-request-row"> <div>
<div className="launcher-request-admin-request-copy"> <div className="launcher-request-admin-kicker">Moderation Tools</div>
<div className="launcher-request-admin-request-title">{requestEntry.title}</div> <h3 className="launcher-request-admin-title">Admin Panel</h3>
<div className="launcher-request-admin-request-meta"> <p className="launcher-request-admin-copy">
<span>{formatRequestStatusLabel(requestEntry.status)}</span> Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place.
<span>{requestEntry.category}</span> </p>
<span>{analysisState}</span> </div>
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span> <div className="launcher-request-admin-stats">
<span>{queuedPendingRequestCount} queued</span>
<span>{needsReviewRequestCount} review</span>
<span>{pendingRequestCount} pending</span>
<span>{activeRequestCount} active</span>
</div>
</div>
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleProcessPendingQueue()}
disabled={queueTriggering}
>
{queueTriggering ? "Starting Queue..." : "Run Pending Queue"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => void refreshAdminData({ includeLogs: true, silentRequests: true })}
disabled={logsLoading}
>
{logsLoading ? "Refreshing..." : "Refresh Admin Data"}
</button>
</div>
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null}
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
{logsError ? <p className="launcher-request-error">{logsError}</p> : null}
<div className="launcher-request-admin-grid">
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Request Management</h4>
<div className="launcher-request-admin-card-hint">Delete actions live here now.</div>
</div>
<div className="launcher-request-admin-request-list">
{requests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
return (
<article key={`admin-${requestEntry.id}`} className="launcher-request-admin-request-row">
<div className="launcher-request-admin-request-copy">
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
<div className="launcher-request-admin-request-meta">
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
<span>{requestEntry.category}</span>
<span>{analysisState}</span>
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
</div>
</div>
<button
type="button"
className="launcher-request-delete-btn"
onClick={() => void handleDeleteRequest(requestEntry)}
disabled={isMutating}
aria-label={`Delete ${requestEntry.title}`}
>
X
</button>
</article>
);
})}
</div>
</section>
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
<div className="launcher-request-admin-card-hint">Newest events first.</div>
</div>
<div className="launcher-request-admin-log-list">
{logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">Loading admin logs...</div>
) : null}
{!logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">No admin logs have been recorded yet.</div>
) : null}
{recentSaveEvents.map((eventEntry, index) => (
<article key={`log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-request-admin-log-row">
<div className="launcher-request-admin-log-head">
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
<div className="launcher-request-admin-log-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
</div> </div>
</div> <div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
<button </article>
type="button" ))}
className="launcher-request-delete-btn" </div>
onClick={() => void handleDeleteRequest(requestEntry)} </section>
disabled={isMutating}
aria-label={`Delete ${requestEntry.title}`}
>
X
</button>
</article>
);
})}
</div> </div>
</section> </>
<section className="launcher-request-admin-card"> )}
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
<div className="launcher-request-admin-card-hint">Newest events first.</div>
</div>
<div className="launcher-request-admin-log-list">
{logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">Loading admin logs...</div>
) : null}
{!logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">No admin logs have been recorded yet.</div>
) : null}
{recentSaveEvents.map((eventEntry, index) => (
<article key={`log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-request-admin-log-row">
<div className="launcher-request-admin-log-head">
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
<div className="launcher-request-admin-log-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
</div>
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
</article>
))}
</div>
</section>
</div>
</section> </section>
) : null} ) : null}
{requestDraftOpen ? ( {requestDraftOpen && !adminPanelOpen ? (
<section className="launcher-request-composer"> <section className="launcher-request-composer">
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft"> <label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
What should be added or improved? What should be added or improved?
@ -833,7 +976,8 @@ function WorldshaperLauncher() {
</div> </div>
</section> </section>
) : null} ) : null}
<div className="launcher-request-list"> {!adminPanelOpen ? (
<div className="launcher-request-list">
{requestsLoading ? ( {requestsLoading ? (
<section className="launcher-request-entry"> <section className="launcher-request-entry">
<div className="launcher-request-empty">Loading saved requests...</div> <div className="launcher-request-empty">Loading saved requests...</div>
@ -908,10 +1052,11 @@ function WorldshaperLauncher() {
</section> </section>
); );
}) : null} }) : null}
</div> </div>
) : null}
<div className="changelog-splash-footer"> <div className="changelog-splash-footer">
<div className="changelog-splash-footnote"> <div className="changelog-splash-footnote">
Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs live in the admin panel. Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs stay behind protected admin access.
</div> </div>
</div> </div>
</div> </div>

View file

@ -255,27 +255,12 @@ body {
padding-right: 4px; padding-right: 4px;
} }
.launcher-request-controls { .launcher-request-hero-actions {
position: sticky;
top: 0;
z-index: 3;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 10px; gap: 12px;
align-items: center; align-items: end;
padding: 12px; margin-top: 16px;
border: 1px solid #365782;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(16, 29, 56, 0.96) 0%, rgba(12, 22, 43, 0.96) 100%);
box-shadow:
0 10px 24px rgba(3, 8, 18, 0.26),
inset 0 0 0 1px rgba(10, 16, 32, 0.14);
}
.launcher-request-toolbar {
display: flex;
justify-content: flex-start;
} }
.launcher-request-toolbar-buttons { .launcher-request-toolbar-buttons {
@ -329,6 +314,17 @@ body {
inset 0 0 0 1px rgba(10, 16, 32, 0.14); inset 0 0 0 1px rgba(10, 16, 32, 0.14);
} }
.launcher-request-admin-unlock {
display: grid;
gap: 12px;
max-width: 420px;
}
.launcher-request-admin-field {
display: grid;
gap: 4px;
}
.launcher-request-admin-head { .launcher-request-admin-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -1918,8 +1914,9 @@ button.danger:not(:disabled):hover {
flex-direction: column; flex-direction: column;
} }
.launcher-request-controls { .launcher-request-hero-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
align-items: stretch;
} }
.launcher-request-admin-head, .launcher-request-admin-head,