Protect launcher admin tools
This commit is contained in:
parent
fad065c429
commit
ab1dfbf029
4 changed files with 384 additions and 151 deletions
|
|
@ -17,6 +17,7 @@ const DEFAULTS = {
|
|||
modelBaseUrl: process.env.REQUEST_ANALYZER_MODEL_BASE_URL || "",
|
||||
model: process.env.REQUEST_ANALYZER_MODEL || "",
|
||||
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),
|
||||
promoteThreshold: Number(process.env.REQUEST_ANALYZER_PROMOTE_THRESHOLD || 0.85),
|
||||
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
|
||||
REQUEST_ANALYZER_API_KEY
|
||||
REQUEST_ANALYZER_ADMIN_PASSWORD
|
||||
REQUEST_ANALYZER_MAX_TOKENS
|
||||
REQUEST_ANALYZER_THINKING
|
||||
REQUEST_ANALYZER_LIMIT
|
||||
REQUEST_ANALYZER_INTERVAL_MS
|
||||
REQUEST_ANALYZER_PROMOTE_THRESHOLD
|
||||
DEEPSEEK_API_KEY
|
||||
LAUNCHER_ADMIN_PASSWORD
|
||||
|
||||
Notes:
|
||||
- DeepSeek uses ${DEFAULT_DEEPSEEK_BASE_URL}/chat/completions.
|
||||
|
|
@ -194,6 +197,17 @@ async function fetchJson(url, init = {}) {
|
|||
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) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
|
|
@ -498,9 +512,9 @@ async function getLauncherRequests(config) {
|
|||
async function patchLauncherRequest(config, requestId, body) {
|
||||
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}`), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
headers: buildAdminHeaders(config, {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
|
@ -508,9 +522,9 @@ async function patchLauncherRequest(config, requestId, body) {
|
|||
async function processLauncherRequestAnalysis(config, requestId, body) {
|
||||
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}/process-analysis`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
headers: buildAdminHeaders(config, {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
81
server.js
81
server.js
|
|
@ -10,6 +10,38 @@ const __dirname = path.dirname(__filename);
|
|||
const app = express();
|
||||
const port = Number(process.env.PORT) || 5180;
|
||||
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() {
|
||||
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) => {
|
||||
if (!requireLauncherAdminAccess(_req, res)) {
|
||||
return;
|
||||
}
|
||||
const contentFiles = Object.fromEntries(
|
||||
Object.entries(contentMap).map(([type, entry]) => {
|
||||
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({
|
||||
ok: true,
|
||||
contentRoot,
|
||||
|
|
@ -2701,6 +2766,9 @@ app.post("/api/launcher-requests", (req, res) => {
|
|||
|
||||
app.patch("/api/launcher-requests/:requestId", (req, res) => {
|
||||
const requestId = String(req.params.requestId || "").trim();
|
||||
if (!requireLauncherAdminAccess(req, res)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = readLauncherRequestsPayload();
|
||||
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) => {
|
||||
const requestId = String(req.params.requestId || "").trim();
|
||||
if (!requireLauncherAdminAccess(req, res)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = readLauncherRequestsPayload();
|
||||
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) => {
|
||||
const requestId = String(req.params.requestId || "").trim();
|
||||
const action = String(req.body?.action || "").trim().toLowerCase();
|
||||
if (!requireLauncherAdminAccess(req, res)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = readLauncherRequestsPayload();
|
||||
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 {
|
||||
const result = launchQueuedRequestAnalysis("manual-api-trigger");
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue