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,27 +762,27 @@ 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>
</div>
<label className="launcher-request-filter"> <label className="launcher-request-filter">
<span className="launcher-request-filter-label">Filter</span> <span className="launcher-request-filter-label">Filter</span>
<select <select
@ -699,8 +801,46 @@ function WorldshaperLauncher() {
</select> </select>
</label> </label>
</div> </div>
</div>
{adminPanelOpen ? ( {adminPanelOpen ? (
<section className="launcher-request-admin-panel"> <section className="launcher-request-admin-panel">
{!adminAccessGranted ? (
<div className="launcher-request-admin-unlock">
<div className="launcher-request-admin-kicker">Protected Tools</div>
<h3 className="launcher-request-admin-title">Admin Access Required</h3>
<p className="launcher-request-admin-copy">
Enter the admin password to manage deletions, run the queue worker, and read request logs.
</p>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Password</span>
<input
type="password"
className="launcher-request-filter-select"
value={adminPasswordDraft}
onChange={(event) => setAdminPasswordDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAdminUnlock();
}
}}
placeholder="Enter admin password"
/>
</label>
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleAdminUnlock()}
disabled={adminAuthSubmitting}
>
{adminAuthSubmitting ? "Unlocking..." : "Unlock Admin Panel"}
</button>
</div>
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
</div>
) : (
<>
<div className="launcher-request-admin-head"> <div className="launcher-request-admin-head">
<div> <div>
<div className="launcher-request-admin-kicker">Moderation Tools</div> <div className="launcher-request-admin-kicker">Moderation Tools</div>
@ -735,6 +875,7 @@ function WorldshaperLauncher() {
</button> </button>
</div> </div>
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null} {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} {logsError ? <p className="launcher-request-error">{logsError}</p> : null}
<div className="launcher-request-admin-grid"> <div className="launcher-request-admin-grid">
<section className="launcher-request-admin-card"> <section className="launcher-request-admin-card">
@ -795,9 +936,11 @@ function WorldshaperLauncher() {
</div> </div>
</section> </section>
</div> </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,6 +976,7 @@ function WorldshaperLauncher() {
</div> </div>
</section> </section>
) : null} ) : null}
{!adminPanelOpen ? (
<div className="launcher-request-list"> <div className="launcher-request-list">
{requestsLoading ? ( {requestsLoading ? (
<section className="launcher-request-entry"> <section className="launcher-request-entry">
@ -909,9 +1053,10 @@ function WorldshaperLauncher() {
); );
}) : 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,