allow custom css
This commit is contained in:
@@ -5,6 +5,13 @@ import {
|
|||||||
subscribeStatus,
|
subscribeStatus,
|
||||||
subscribeUnread,
|
subscribeUnread,
|
||||||
} from "./notify-client.js";
|
} from "./notify-client.js";
|
||||||
|
|
||||||
|
(function injectCustomCSS() {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = "/assets/custom.css";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
})();
|
||||||
import { mountNav } from "./nav/template.js";
|
import { mountNav } from "./nav/template.js";
|
||||||
import { createAdaptiveNavController } from "./nav/layout.js";
|
import { createAdaptiveNavController } from "./nav/layout.js";
|
||||||
import { applyUserRoleUI } from "./nav/role-ui.js";
|
import { applyUserRoleUI } from "./nav/role-ui.js";
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const tlsCertbotCertFileInput = document.getElementById("tls-certbot-cert-file")
|
|||||||
const tlsCertbotKeyFileInput = document.getElementById("tls-certbot-key-file");
|
const tlsCertbotKeyFileInput = document.getElementById("tls-certbot-key-file");
|
||||||
const tlsCertbotCaFileInput = document.getElementById("tls-certbot-ca-file");
|
const tlsCertbotCaFileInput = document.getElementById("tls-certbot-ca-file");
|
||||||
|
|
||||||
|
const appearanceForm = document.getElementById("appearance-form");
|
||||||
|
const appearancePermissionNote = document.getElementById("appearance-permission-note");
|
||||||
|
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
|
||||||
|
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let securityConfig = null;
|
let securityConfig = null;
|
||||||
let tlsConfig = null;
|
let tlsConfig = null;
|
||||||
@@ -605,12 +610,68 @@ async function handleUnbanClick(event) {
|
|||||||
await loadBannedIps();
|
await loadBannedIps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAppearanceSettings() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
if (!isAdmin(currentUser.role)) {
|
||||||
|
if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden");
|
||||||
|
if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true;
|
||||||
|
if (appearanceSaveBtn) appearanceSaveBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appearancePermissionNote) appearancePermissionNote.classList.add("hidden");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/appearance", { credentials: "include" });
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("Failed to load custom CSS settings.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (appearanceCustomCssInput) appearanceCustomCssInput.value = data.customCSS || "";
|
||||||
|
if (appearanceSaveBtn) appearanceSaveBtn.disabled = false;
|
||||||
|
} catch {
|
||||||
|
showMessage("Failed to load custom CSS settings.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAppearanceSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isAdmin(currentUser?.role)) {
|
||||||
|
showMessage("Admin role required.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customCSS = appearanceCustomCssInput ? appearanceCustomCssInput.value : "";
|
||||||
|
if (customCSS.length > 51200) {
|
||||||
|
showMessage("CSS exceeds the 50 KB size limit.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/settings/appearance", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ customCSS }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage(data.error || "Failed to save custom CSS.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("Custom CSS saved. Reload the page to apply the new styles.");
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
await loadCurrentUser();
|
await loadCurrentUser();
|
||||||
loadPrefs();
|
loadPrefs();
|
||||||
await loadSecurityPolicy();
|
await loadSecurityPolicy();
|
||||||
await loadTlsSettings();
|
await loadTlsSettings();
|
||||||
|
await loadAppearanceSettings();
|
||||||
await loadBannedIps();
|
await loadBannedIps();
|
||||||
|
|
||||||
passwordForm.addEventListener("submit", updatePassword);
|
passwordForm.addEventListener("submit", updatePassword);
|
||||||
@@ -618,6 +679,7 @@ async function init() {
|
|||||||
securityForm.addEventListener("submit", saveSecurityPolicy);
|
securityForm.addEventListener("submit", saveSecurityPolicy);
|
||||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||||
|
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
||||||
refreshBansBtn.addEventListener("click", loadBannedIps);
|
refreshBansBtn.addEventListener("click", loadBannedIps);
|
||||||
bansTableBody.addEventListener("click", handleUnbanClick);
|
bansTableBody.addEventListener("click", handleUnbanClick);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/assets/main.css" />
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/custom.css" />
|
||||||
<script
|
<script
|
||||||
src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.2/anime.min.js"
|
src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.2/anime.min.js"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
|
|||||||
@@ -337,6 +337,37 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form id="appearance-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Custom CSS</h3>
|
||||||
|
<p class="text-xs text-slate-400">Admin-only. Paste custom CSS to override the default theme across all pages.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||||
|
Only admins can update custom CSS.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-slate-300">Custom CSS</span>
|
||||||
|
<textarea
|
||||||
|
id="appearance-custom-css"
|
||||||
|
rows="12"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="/* Paste your custom CSS here */ :root { --color-primary: #f59e0b; }"
|
||||||
|
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500 font-mono text-xs resize-y"
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Max 50 KB. Reload any open page after saving to see the new styles.</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="appearance-save-btn"
|
||||||
|
class="px-4 py-2 rounded-lg bg-violet-700 hover:bg-violet-600 transition-colors text-white font-medium"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-paintbrush mr-2"></i>Save Custom CSS
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2"></p>
|
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export interface Config {
|
|||||||
enrollment: {
|
enrollment: {
|
||||||
requireApproval: boolean;
|
requireApproval: boolean;
|
||||||
};
|
};
|
||||||
|
appearance: {
|
||||||
|
customCSS: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: Config = {
|
const DEFAULT_CONFIG: Config = {
|
||||||
@@ -108,6 +111,9 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
enrollment: {
|
enrollment: {
|
||||||
requireApproval: true,
|
requireApproval: true,
|
||||||
},
|
},
|
||||||
|
appearance: {
|
||||||
|
customCSS: "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type SaveSecrets = {
|
type SaveSecrets = {
|
||||||
@@ -423,6 +429,9 @@ export function loadConfig(): Config {
|
|||||||
? String(process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL).toLowerCase() === "true"
|
? String(process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL).toLowerCase() === "true"
|
||||||
: (fileConfig.enrollment?.requireApproval ?? DEFAULT_CONFIG.enrollment.requireApproval),
|
: (fileConfig.enrollment?.requireApproval ?? DEFAULT_CONFIG.enrollment.requireApproval),
|
||||||
},
|
},
|
||||||
|
appearance: {
|
||||||
|
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (saveChanged) {
|
if (saveChanged) {
|
||||||
@@ -591,3 +600,18 @@ export async function updateTlsConfig(
|
|||||||
await writePersistentFileConfig(fileConfig);
|
await writePersistentFileConfig(fileConfig);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAppearanceConfig(customCSS: string): Promise<Config["appearance"]> {
|
||||||
|
const current = getConfig();
|
||||||
|
const next = { customCSS: String(customCSS || "").slice(0, 51200) };
|
||||||
|
|
||||||
|
configCache = {
|
||||||
|
...current,
|
||||||
|
appearance: next,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileConfig = readFileConfigForUpdate();
|
||||||
|
fileConfig.appearance = next;
|
||||||
|
await writePersistentFileConfig(fileConfig);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { getConfig } from "../../config";
|
||||||
|
|
||||||
type AssetsRouteDeps = {
|
type AssetsRouteDeps = {
|
||||||
PUBLIC_ROOT: string;
|
PUBLIC_ROOT: string;
|
||||||
@@ -11,6 +12,16 @@ export async function handleAssetsRoutes(
|
|||||||
url: URL,
|
url: URL,
|
||||||
deps: AssetsRouteDeps,
|
deps: AssetsRouteDeps,
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
|
if (req.method === "GET" && url.pathname === "/assets/custom.css") {
|
||||||
|
const css = getConfig().appearance?.customCSS || "";
|
||||||
|
return new Response(css, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/css; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/favicon.ico") {
|
if (req.method === "GET" && url.pathname === "/favicon.ico") {
|
||||||
const file = Bun.file(path.join(deps.PUBLIC_ROOT, "assets", "favicon.ico"));
|
const file = Bun.file(path.join(deps.PUBLIC_ROOT, "assets", "favicon.ico"));
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||||
import { getConfig, updateSecurityConfig, updateTlsConfig } from "../../config";
|
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig } from "../../config";
|
||||||
import { getClientMetricsSummary } from "../../db";
|
import { getClientMetricsSummary } from "../../db";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission } from "../../rbac";
|
||||||
@@ -397,6 +397,50 @@ export async function handleMiscRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/settings/appearance") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
return Response.json(
|
||||||
|
{ customCSS: getConfig().appearance?.customCSS || "" },
|
||||||
|
{ headers: deps.CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customCSS = typeof body?.customCSS === "string" ? body.customCSS : "";
|
||||||
|
if (customCSS.length > 51200) {
|
||||||
|
return Response.json({ error: "CSS exceeds 50 KB limit" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateAppearanceConfig(customCSS);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: deps.requestIP?.(req)?.address || "unknown",
|
||||||
|
action: AuditAction.COMMAND,
|
||||||
|
details: "Updated custom CSS",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true, customCSS: updated.customCSS }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
|
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(deps.tlsCertPath);
|
const file = Bun.file(deps.tlsCertPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user