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 || "",
|
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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
server.js
81
server.js
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue