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

@ -95,6 +95,13 @@ type ProcessPendingPayload = {
pid?: number;
};
type AdminAuthPayload = {
ok?: boolean;
accessGranted?: boolean;
adminConfigured?: boolean;
error?: string;
};
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
async function resolveDefaultWorldId(): Promise<string> {
@ -122,6 +129,25 @@ async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit)
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 {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
@ -281,6 +307,11 @@ function WorldshaperLauncher() {
const [requestFilter, setRequestFilter] = useState("all");
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
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 [logsLoading, setLogsLoading] = useState(false);
const [logsError, setLogsError] = useState("");
@ -329,19 +360,37 @@ function WorldshaperLauncher() {
async function loadRecentSaveEvents(): Promise<void> {
setLogsLoading(true);
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 : []);
setLogsError("");
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
}
setLogsError(String(nextError || "Failed to load admin logs."));
} finally {
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> {
await loadRequests({ silent: options?.silentRequests === true });
if (options?.includeLogs) {
if (options?.includeLogs && adminAccessGranted && adminPassword) {
await loadRecentSaveEvents();
}
}
@ -351,11 +400,11 @@ function WorldshaperLauncher() {
}, []);
useEffect(() => {
if (!adminPanelOpen) {
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
}
void loadRecentSaveEvents();
}, [adminPanelOpen]);
}, [adminPanelOpen, adminAccessGranted, adminPassword]);
useEffect(() => {
if (activeBoardTab !== "requests") {
@ -371,11 +420,13 @@ function WorldshaperLauncher() {
} catch {
// Keep the current list visible during background refresh failures.
}
if (!adminPanelOpen) {
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
}
try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves");
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
headers: buildAdminHeaders(adminPassword),
});
if (!cancelled) {
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
}
@ -390,7 +441,7 @@ function WorldshaperLauncher() {
cancelled = true;
window.clearInterval(intervalId);
};
}, [activeBoardTab, adminPanelOpen]);
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
async function handleLaunch(): Promise<void> {
setError("");
@ -436,11 +487,11 @@ function WorldshaperLauncher() {
setRequestDraftOpen(false);
setRequestsError("");
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
if (adminPanelOpen) {
if (adminPanelOpen && adminAccessGranted) {
void loadRecentSaveEvents();
}
window.setTimeout(() => {
void refreshAdminData({ includeLogs: adminPanelOpen, silentRequests: true });
void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true });
}, 3500);
} catch (nextError: unknown) {
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 {
setExpandedRequestIds((current) => (
current.includes(requestId)
@ -466,6 +556,7 @@ function WorldshaperLauncher() {
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
method: "DELETE",
headers: buildAdminHeaders(adminPassword),
});
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError("");
@ -475,6 +566,11 @@ function WorldshaperLauncher() {
void loadRecentSaveEvents();
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setRequestsError(String(nextError || "Failed to delete request."));
} finally {
setRequestMutatingId("");
@ -486,6 +582,7 @@ function WorldshaperLauncher() {
try {
const payload = await fetchJsonOrThrow<ProcessPendingPayload>("/api/launcher-requests/process-pending", {
method: "POST",
headers: buildAdminHeaders(adminPassword),
});
if (payload.launched) {
setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`);
@ -508,6 +605,11 @@ function WorldshaperLauncher() {
}, 4200);
}
} 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."));
} finally {
setQueueTriggering(false);
@ -660,144 +762,185 @@ function WorldshaperLauncher() {
<div className="changelog-splash-meta">
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
</div>
</div>
<div className="launcher-request-controls">
<div className="launcher-request-toolbar">
<div className="launcher-request-hero-actions">
<div className="launcher-request-toolbar-buttons">
<button
type="button"
className="launcher-primary-btn"
onClick={() => setRequestDraftOpen((value) => !value)}
onClick={() => {
setAdminPanelOpen(false);
setRequestDraftOpen((value) => !value);
}}
disabled={requestSubmitting}
>
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
{requestDraftOpen && !adminPanelOpen ? "Hide Request Form" : "Add New Request"}
</button>
<button
type="button"
className={`launcher-secondary-btn ${adminPanelOpen ? "is-active" : ""}`}
onClick={() => setAdminPanelOpen((value) => !value)}
onClick={() => void handleAdminPanelToggle()}
>
{adminPanelOpen ? "Hide Admin Panel" : "Admin Panel"}
</button>
</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>
<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>
{adminPanelOpen ? (
<section className="launcher-request-admin-panel">
<div className="launcher-request-admin-head">
<div>
<div className="launcher-request-admin-kicker">Moderation Tools</div>
<h3 className="launcher-request-admin-title">Admin Panel</h3>
{!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">
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>
</div>
<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}
{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>
<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>
<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>
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
</div>
) : (
<>
<div className="launcher-request-admin-head">
<div>
<div className="launcher-request-admin-kicker">Moderation Tools</div>
<h3 className="launcher-request-admin-title">Admin Panel</h3>
<p className="launcher-request-admin-copy">
Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place.
</p>
</div>
<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>
<button
type="button"
className="launcher-request-delete-btn"
onClick={() => void handleDeleteRequest(requestEntry)}
disabled={isMutating}
aria-label={`Delete ${requestEntry.title}`}
>
X
</button>
</article>
);
})}
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
</article>
))}
</div>
</section>
</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>
) : null}
{requestDraftOpen ? (
{requestDraftOpen && !adminPanelOpen ? (
<section className="launcher-request-composer">
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
What should be added or improved?
@ -833,7 +976,8 @@ function WorldshaperLauncher() {
</div>
</section>
) : null}
<div className="launcher-request-list">
{!adminPanelOpen ? (
<div className="launcher-request-list">
{requestsLoading ? (
<section className="launcher-request-entry">
<div className="launcher-request-empty">Loading saved requests...</div>
@ -908,10 +1052,11 @@ function WorldshaperLauncher() {
</section>
);
}) : null}
</div>
</div>
) : null}
<div className="changelog-splash-footer">
<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>

View file

@ -255,27 +255,12 @@ body {
padding-right: 4px;
}
.launcher-request-controls {
position: sticky;
top: 0;
z-index: 3;
.launcher-request-hero-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px;
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;
gap: 12px;
align-items: end;
margin-top: 16px;
}
.launcher-request-toolbar-buttons {
@ -329,6 +314,17 @@ body {
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 {
display: flex;
justify-content: space-between;
@ -1918,8 +1914,9 @@ button.danger:not(:disabled):hover {
flex-direction: column;
}
.launcher-request-controls {
.launcher-request-hero-actions {
grid-template-columns: 1fr;
align-items: stretch;
}
.launcher-request-admin-head,