custom branding

This commit is contained in:
KingKDot
2026-07-08 21:47:57 -05:00
parent faac5b6029
commit b29d5b4291
13 changed files with 735 additions and 31 deletions
+9
View File
@@ -65,10 +65,19 @@
# Use a logo URL, or leave it empty and set a Font Awesome icon class.
# Logo URLs may be absolute http(s) URLs or root-relative paths such as /assets/logo.png.
# OVERLORD_LOGIN_BRAND_NAME=Acme SOC
# OVERLORD_NAV_BRAND_NAME=Acme Console
# OVERLORD_BRAND_ACCENT_COLOR=#14b8a6
# OVERLORD_LOGIN_TITLE=Welcome to Acme Overlord
# OVERLORD_LOGIN_SUBTITLE=Sign in with your Acme identity
# OVERLORD_LOGIN_LOGO_URL=/assets/acme-logo.png
# OVERLORD_LOGIN_LOGO_ALT=Acme logo
# OVERLORD_NAV_LOGO_URL=/assets/acme-nav-logo.png
# OVERLORD_NAV_LOGO_ALT=Acme logo
# OVERLORD_LOGIN_HERO_IMAGE_URL=/assets/acme-login.jpg
# OVERLORD_LOGIN_HERO_IMAGE_ALT=Acme operations center
# OVERLORD_LOGIN_FOOTER_TEXT=Authorized Acme access only
# OVERLORD_LOGIN_SUPPORT_TEXT=Need access help?
# OVERLORD_LOGIN_SUPPORT_URL=https://help.example.com
# OVERLORD_LOGIN_ICON_CLASS=fa-solid fa-shield-halved
# ============================================
+32
View File
@@ -11,6 +11,9 @@ const loginTitle = document.getElementById("login-title");
const loginSubtitle = document.getElementById("login-subtitle");
const loginIcon = document.getElementById("login-icon");
const loginLogo = document.getElementById("login-logo");
const loginHeroImage = document.getElementById("login-hero-image");
const loginBrandFooter = document.getElementById("login-brand-footer");
const loginSupportLink = document.getElementById("login-support-link");
(() => {
const params = new URLSearchParams(window.location.search);
@@ -31,6 +34,7 @@ const loginLogo = document.getElementById("login-logo");
if (loginTitle && data.title) loginTitle.textContent = data.title;
if (loginSubtitle && data.subtitle) loginSubtitle.textContent = data.subtitle;
if (data.productName) document.title = `${data.productName} Login`;
if (data.accentColor) document.documentElement.style.setProperty("--brand-accent", data.accentColor);
if (loginIcon && data.iconClass) {
loginIcon.className = `${data.iconClass} login-crown`;
@@ -48,6 +52,34 @@ const loginLogo = document.getElementById("login-logo");
};
loginLogo.src = data.logoUrl;
}
if (loginHeroImage && data.heroImageUrl) {
loginHeroImage.alt = data.heroImageAlt || "";
loginHeroImage.onload = () => {
loginHeroImage.style.display = "";
document.body.classList.add("login-has-hero-image");
};
loginHeroImage.onerror = () => {
loginHeroImage.style.display = "none";
document.body.classList.remove("login-has-hero-image");
};
loginHeroImage.src = data.heroImageUrl;
}
if (loginBrandFooter && data.footerText) {
loginBrandFooter.textContent = data.footerText;
loginBrandFooter.style.display = "";
}
if (loginSupportLink && data.supportText && data.supportUrl) {
loginSupportLink.textContent = data.supportText;
loginSupportLink.href = data.supportUrl;
if (!data.supportUrl.startsWith("/")) {
loginSupportLink.target = "_blank";
loginSupportLink.rel = "noopener noreferrer";
}
loginSupportLink.style.display = "";
}
} catch {}
})();
+72 -4
View File
@@ -340,6 +340,27 @@ a {
overflow: hidden;
}
.login-hero-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.38;
filter: saturate(0.9) contrast(1.05);
pointer-events: none;
}
.login-body.login-has-hero-image::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(5, 7, 15, 0.88), rgba(5, 7, 15, 0.64)),
radial-gradient(75% 75% at 50% 25%, transparent, rgba(5, 7, 15, 0.62));
pointer-events: none;
}
.login-body::before {
content: "";
position: absolute;
@@ -984,6 +1005,7 @@ article.card[data-bookmarked="true"] {
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 1;
}
.particle {
@@ -1031,6 +1053,7 @@ article.card[data-bookmarked="true"] {
0 0 100px rgba(92, 145, 255, 0.1);
position: relative;
opacity: 0;
z-index: 2;
}
.login-card::before {
@@ -1056,8 +1079,8 @@ article.card[data-bookmarked="true"] {
.login-crown {
font-size: 48px;
color: #7a5bff;
filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.5));
color: var(--brand-accent, #7a5bff);
filter: drop-shadow(0 4px 12px color-mix(in srgb, var(--brand-accent, #7a5bff) 50%, transparent));
}
.login-logo {
@@ -1065,7 +1088,7 @@ article.card[data-bookmarked="true"] {
height: 56px;
object-fit: contain;
border-radius: 12px;
filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.35));
filter: drop-shadow(0 4px 12px color-mix(in srgb, var(--brand-accent, #7a5bff) 35%, transparent));
}
.login-brand {
@@ -1074,7 +1097,7 @@ article.card[data-bookmarked="true"] {
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 12px;
background: linear-gradient(135deg, #7a5bff, #5672ff);
background: linear-gradient(135deg, var(--brand-accent, #7a5bff), #7dd3fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -1097,6 +1120,24 @@ article.card[data-bookmarked="true"] {
opacity: 0;
}
.login-enterprise-footer,
.login-support-link {
display: block;
text-align: center;
font-size: 0.78rem;
color: #94a3b8;
margin-top: 12px;
}
.login-support-link {
color: #c4b5fd;
text-decoration: none;
}
.login-support-link:hover {
color: #e0e7ff;
}
.form-group {
margin-bottom: 20px;
opacity: 0;
@@ -4456,6 +4497,33 @@ body.sb-collapsed .sb-link .sb-text {
text-decoration: none;
}
.nav-brand-link {
min-width: 0;
}
.nav-brand-logo {
width: 24px;
height: 24px;
object-fit: contain;
border-radius: 6px;
flex: 0 0 auto;
filter: drop-shadow(0 2px 8px color-mix(in srgb, var(--brand-accent, #7a5bff) 28%, transparent));
}
.sb-logo .nav-brand-logo,
.sb-mobile-brand .nav-brand-logo {
width: 22px;
height: 22px;
}
#nav-brand-name,
#nav-mobile-brand-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Mobile backdrop ---- */
#sb-backdrop {
display: none;
+42
View File
@@ -22,6 +22,7 @@ import {
const host = document.getElementById("top-nav");
if (host) {
const refs = mountNav(host);
applyBranding();
initCommandPalette();
import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => {
runWithoutPageTracking(() => showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host));
@@ -246,3 +247,44 @@ if (host) {
setupSoftNavigation(host, { onPathChange: applyActivePath });
startPageTracking();
}
async function applyBranding() {
try {
const res = await fetch("/api/login/branding");
if (!res.ok) return;
const brand = await res.json();
if (brand.accentColor) {
document.documentElement.style.setProperty("--brand-accent", brand.accentColor);
}
const navName = brand.navName || brand.productName || "Overlord";
for (const el of document.querySelectorAll("#nav-brand-name, #nav-mobile-brand-name")) {
el.textContent = navName;
}
const iconClass = brand.iconClass || "fa-solid fa-crown";
for (const icon of document.querySelectorAll("#nav-brand-icon, #nav-mobile-brand-icon")) {
const sidebarIcon = Boolean(icon.closest(".sb-logo"));
icon.className = `${iconClass} header-crown${sidebarIcon ? " sb-icon" : ""}`;
}
const logoUrl = brand.navLogoUrl || brand.logoUrl || "";
if (!logoUrl) return;
for (const logo of document.querySelectorAll("#nav-brand-logo, #nav-mobile-brand-logo")) {
const pairedIcon = logo.id === "nav-mobile-brand-logo"
? document.getElementById("nav-mobile-brand-icon")
: document.getElementById("nav-brand-icon");
logo.alt = brand.navLogoAlt || brand.logoAlt || `${navName} logo`;
logo.onload = () => {
logo.style.display = "";
if (pairedIcon) pairedIcon.style.display = "none";
};
logo.onerror = () => {
logo.style.display = "none";
if (pairedIcon) pairedIcon.style.display = "";
};
logo.src = logoUrl;
}
} catch {}
}
+11 -7
View File
@@ -133,8 +133,10 @@ function mountTopbar(host) {
host.innerHTML = `
<div class="topbar-grid">
<div class="topbar-left">
<a href="/" class="flex items-center gap-2 font-semibold tracking-wide">
<i class="fa-solid fa-crown header-crown"></i> Overlord
<a href="/" class="nav-brand-link flex items-center gap-2 font-semibold tracking-wide">
<img id="nav-brand-logo" class="nav-brand-logo" alt="Overlord logo" style="display: none" />
<i id="nav-brand-icon" class="fa-solid fa-crown header-crown"></i>
<span id="nav-brand-name">Overlord</span>
</a>
<button id="topbar-nav-toggle" class="topbar-nav-toggle" aria-label="Open navigation menu" aria-expanded="false" type="button">
<i class="fa-solid fa-layer-group"></i>
@@ -281,9 +283,10 @@ function mountSidebar(host) {
host.innerHTML = `
<div class="sb-header">
<a href="/" class="sb-logo">
<i class="fa-solid fa-crown header-crown sb-icon"></i>
<span class="sb-text">Overlord</span>
<a href="/" class="sb-logo nav-brand-link">
<img id="nav-brand-logo" class="nav-brand-logo sb-icon" alt="Overlord logo" style="display: none" />
<i id="nav-brand-icon" class="fa-solid fa-crown header-crown sb-icon"></i>
<span id="nav-brand-name" class="sb-text">Overlord</span>
</a>
<button id="sb-collapse-btn" class="sb-collapse-btn" title="Toggle sidebar" aria-label="Toggle sidebar">
<i class="fa-solid fa-chevron-left"></i>
@@ -332,8 +335,9 @@ function mountSidebar(host) {
<i class="fa-solid fa-bars"></i>
</button>
<a href="/" class="sb-mobile-brand">
<i class="fa-solid fa-crown header-crown" style="font-size:0.85rem"></i>
<span>Overlord</span>
<img id="nav-mobile-brand-logo" class="nav-brand-logo" alt="Overlord logo" style="display: none" />
<i id="nav-mobile-brand-icon" class="fa-solid fa-crown header-crown" style="font-size:0.85rem"></i>
<span id="nav-mobile-brand-name">Overlord</span>
</a>
`;
host.insertAdjacentElement("afterend", mobileBar);
+162 -8
View File
@@ -108,6 +108,24 @@ 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");
const brandProductNameInput = document.getElementById("brand-product-name");
const brandNavNameInput = document.getElementById("brand-nav-name");
const brandAccentColorInput = document.getElementById("brand-accent-color");
const brandIconClassInput = document.getElementById("brand-icon-class");
const brandNavLogoUrlInput = document.getElementById("brand-nav-logo-url");
const brandNavLogoFileInput = document.getElementById("brand-nav-logo-file");
const brandNavLogoAltInput = document.getElementById("brand-nav-logo-alt");
const brandLoginLogoUrlInput = document.getElementById("brand-login-logo-url");
const brandLoginLogoFileInput = document.getElementById("brand-login-logo-file");
const brandLoginLogoAltInput = document.getElementById("brand-login-logo-alt");
const brandLoginTitleInput = document.getElementById("brand-login-title");
const brandLoginSubtitleInput = document.getElementById("brand-login-subtitle");
const brandHeroImageUrlInput = document.getElementById("brand-hero-image-url");
const brandHeroImageFileInput = document.getElementById("brand-hero-image-file");
const brandHeroImageAltInput = document.getElementById("brand-hero-image-alt");
const brandFooterTextInput = document.getElementById("brand-footer-text");
const brandSupportTextInput = document.getElementById("brand-support-text");
const brandSupportUrlInput = document.getElementById("brand-support-url");
const exportImportSection = document.getElementById("export-import-section");
const exportSettingsBtn = document.getElementById("export-settings-btn");
@@ -1354,13 +1372,146 @@ function initSettingsSidebar() {
}, { passive: true });
}
const brandInputs = [
brandProductNameInput,
brandNavNameInput,
brandAccentColorInput,
brandIconClassInput,
brandNavLogoUrlInput,
brandNavLogoFileInput,
brandNavLogoAltInput,
brandLoginLogoUrlInput,
brandLoginLogoFileInput,
brandLoginLogoAltInput,
brandLoginTitleInput,
brandLoginSubtitleInput,
brandHeroImageUrlInput,
brandHeroImageFileInput,
brandHeroImageAltInput,
brandFooterTextInput,
brandSupportTextInput,
brandSupportUrlInput,
];
function setAppearanceFormDisabled(disabled) {
for (const input of [...brandInputs, appearanceCustomCssInput, appearanceSaveBtn]) {
if (input) input.disabled = disabled;
}
}
function applyBrandingForm(loginBranding = {}) {
if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord";
if (brandNavNameInput) brandNavNameInput.value = loginBranding.navName || loginBranding.productName || "Overlord";
if (brandAccentColorInput) brandAccentColorInput.value = /^#[0-9a-fA-F]{6}$/.test(loginBranding.accentColor || "") ? loginBranding.accentColor : "#7a5bff";
if (brandIconClassInput) brandIconClassInput.value = loginBranding.iconClass || "fa-solid fa-crown";
if (brandNavLogoUrlInput) brandNavLogoUrlInput.value = loginBranding.navLogoUrl || "";
if (brandNavLogoAltInput) brandNavLogoAltInput.value = loginBranding.navLogoAlt || "";
if (brandLoginLogoUrlInput) brandLoginLogoUrlInput.value = loginBranding.logoUrl || "";
if (brandLoginLogoAltInput) brandLoginLogoAltInput.value = loginBranding.logoAlt || "";
if (brandLoginTitleInput) brandLoginTitleInput.value = loginBranding.title || "Welcome back";
if (brandLoginSubtitleInput) brandLoginSubtitleInput.value = loginBranding.subtitle || "Sign in to your control plane";
if (brandHeroImageUrlInput) brandHeroImageUrlInput.value = loginBranding.heroImageUrl || "";
if (brandHeroImageAltInput) brandHeroImageAltInput.value = loginBranding.heroImageAlt || "";
if (brandFooterTextInput) brandFooterTextInput.value = loginBranding.footerText || "";
if (brandSupportTextInput) brandSupportTextInput.value = loginBranding.supportText || "";
if (brandSupportUrlInput) brandSupportUrlInput.value = loginBranding.supportUrl || "";
}
function collectBrandingForm() {
return {
productName: String(brandProductNameInput?.value || "").trim(),
navName: String(brandNavNameInput?.value || "").trim(),
accentColor: String(brandAccentColorInput?.value || "").trim(),
iconClass: String(brandIconClassInput?.value || "").trim(),
navLogoUrl: String(brandNavLogoUrlInput?.value || "").trim(),
navLogoAlt: String(brandNavLogoAltInput?.value || "").trim(),
logoUrl: String(brandLoginLogoUrlInput?.value || "").trim(),
logoAlt: String(brandLoginLogoAltInput?.value || "").trim(),
title: String(brandLoginTitleInput?.value || "").trim(),
subtitle: String(brandLoginSubtitleInput?.value || "").trim(),
heroImageUrl: String(brandHeroImageUrlInput?.value || "").trim(),
heroImageAlt: String(brandHeroImageAltInput?.value || "").trim(),
footerText: String(brandFooterTextInput?.value || "").trim(),
supportText: String(brandSupportTextInput?.value || "").trim(),
supportUrl: String(brandSupportUrlInput?.value || "").trim(),
};
}
function setBrandingUploadName(fileInput) {
const label = document.getElementById(`${fileInput.id}-name`);
if (label) label.textContent = fileInput.files?.[0]?.name || "No file chosen";
}
async function uploadBrandingImage(fileInput, targetInput, kind, label) {
const file = fileInput?.files?.[0];
if (!file || !targetInput) return;
if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) {
fileInput.value = "";
return;
}
if (file.size > 5 * 1024 * 1024) {
showMessage("Branding images must be 5 MB or smaller.", "error");
fileInput.value = "";
return;
}
const form = new FormData();
form.append("kind", kind);
form.append("file", file);
fileInput.disabled = true;
showMessage(`Uploading ${label}...`);
try {
const res = await fetch("/api/settings/appearance/image", {
method: "POST",
credentials: "include",
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || `Failed to upload ${label}.`, "error");
return;
}
targetInput.value = data.url || "";
showMessage(`${label} uploaded. Save Branding to apply it.`);
} catch {
showMessage(`Failed to upload ${label}.`, "error");
} finally {
fileInput.disabled = false;
fileInput.value = "";
setBrandingUploadName(fileInput);
}
}
function initBrandingUploads() {
if (brandNavLogoFileInput) {
brandNavLogoFileInput.addEventListener("change", () => {
setBrandingUploadName(brandNavLogoFileInput);
uploadBrandingImage(brandNavLogoFileInput, brandNavLogoUrlInput, "nav-logo", "Navigation logo");
});
}
if (brandLoginLogoFileInput) {
brandLoginLogoFileInput.addEventListener("change", () => {
setBrandingUploadName(brandLoginLogoFileInput);
uploadBrandingImage(brandLoginLogoFileInput, brandLoginLogoUrlInput, "login-logo", "Sign-in logo");
});
}
if (brandHeroImageFileInput) {
brandHeroImageFileInput.addEventListener("change", () => {
setBrandingUploadName(brandHeroImageFileInput);
uploadBrandingImage(brandHeroImageFileInput, brandHeroImageUrlInput, "hero-image", "Sign-in background image");
});
}
}
async function loadAppearanceSettings() {
if (!currentUser) return;
if (!userHas("system:appearance")) {
if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden");
if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true;
if (appearanceSaveBtn) appearanceSaveBtn.disabled = true;
setAppearanceFormDisabled(true);
return;
}
@@ -1369,14 +1520,15 @@ async function loadAppearanceSettings() {
try {
const res = await fetch("/api/settings/appearance", { credentials: "include" });
if (!res.ok) {
showMessage("Failed to load custom CSS settings.", "error");
showMessage("Failed to load branding settings.", "error");
return;
}
const data = await res.json().catch(() => ({}));
if (appearanceCustomCssInput) appearanceCustomCssInput.value = data.customCSS || "";
if (appearanceSaveBtn) appearanceSaveBtn.disabled = false;
applyBrandingForm(data.loginBranding || {});
setAppearanceFormDisabled(false);
} catch {
showMessage("Failed to load custom CSS settings.", "error");
showMessage("Failed to load branding settings.", "error");
}
}
@@ -1396,16 +1548,17 @@ async function saveAppearanceSettings(event) {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ customCSS }),
body: JSON.stringify({ customCSS, loginBranding: collectBrandingForm() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save custom CSS.", "error");
showMessage(data.error || "Failed to save branding.", "error");
return;
}
showMessage("Custom CSS saved. Reload the page to apply the new styles.");
applyBrandingForm(data.loginBranding || collectBrandingForm());
showMessage("Branding saved. Reload any open page to apply it everywhere.");
}
const chatSettingsSection = document.getElementById("chat-settings-section");
@@ -2620,6 +2773,7 @@ async function init() {
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings);
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
initBrandingUploads();
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
if (inputArchiveAdminForm) inputArchiveAdminForm.addEventListener("submit", saveInputArchiveAdminSettings);
+3
View File
@@ -14,6 +14,7 @@
<script src="/vendor/animejs/anime.min.js"></script>
</head>
<body class="login-body">
<img id="login-hero-image" class="login-hero-image" alt="" style="display: none" />
<div class="login-particles">
<div class="particle"></div>
<div class="particle"></div>
@@ -88,6 +89,8 @@
Don't have an account?
<a href="/register.html">Create one</a>
</div>
<div id="login-brand-footer" class="login-enterprise-footer" style="display: none"></div>
<a id="login-support-link" class="login-support-link" href="#" style="display: none"></a>
</div>
<script src="/assets/login.js"></script>
</body>
+128 -8
View File
@@ -19,12 +19,19 @@
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
color: rgb(203 213 225); /* slate-300 */
cursor: pointer;
transition: background-color 120ms, color 120ms;
}
.settings-nav-link span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-nav-link:hover {
background-color: rgb(30 41 59 / 0.7); /* slate-800/70 */
color: rgb(241 245 249); /* slate-100 */
@@ -56,7 +63,26 @@
.settings-section { scroll-margin-top: var(--settings-scroll-mt); }
/* Also pin the sticky sidebar below the topbar so it doesn't slide
under the nav when the page is scrolled. */
#settings-sidebar { top: var(--settings-scroll-mt); }
#settings-sidebar {
top: var(--settings-scroll-mt);
max-height: calc(100vh - var(--settings-scroll-mt) - 1rem);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgb(100 116 139 / 0.35) transparent;
}
#settings-sidebar::-webkit-scrollbar { width: 5px; }
#settings-sidebar::-webkit-scrollbar-track { background: transparent; }
#settings-sidebar::-webkit-scrollbar-thumb {
background: rgb(100 116 139 / 0.35);
border-radius: 999px;
}
@media (max-width: 1023px) {
#settings-sidebar {
max-height: none;
overflow: visible;
}
}
</style>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
@@ -100,7 +126,7 @@
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:thumbnails" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
<a data-target="input-archive-admin-section" class="settings-nav-link" data-permission="system:input-archive" style="--accent: 250 204 21"><i class="fa-solid fa-database w-4 text-yellow-400"></i><span>Input Archive Admin</span></a>
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:build-limits" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:appearance" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:appearance" style="--accent: 232 121 249"><i class="fa-solid fa-palette w-4 text-fuchsia-400"></i><span>Branding</span></a>
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:export-import" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
<a data-target="section-health" class="settings-nav-link" data-permission="system:health" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
</nav>
@@ -1220,24 +1246,118 @@
<p id="brl-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<!-- Custom CSS -->
<!-- Branding & Custom CSS -->
<section id="section-appearance" data-permission="system:appearance" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
<i class="fa-solid fa-palette text-fuchsia-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Custom CSS</h2>
<p class="text-sm text-slate-400">Admin-only. Paste custom CSS to override the default theme across all pages.</p>
<h2 class="text-lg font-semibold">Branding</h2>
<p class="text-sm text-slate-400">Admin-only. Customize enterprise identity across navigation and sign-in.</p>
</div>
</div>
<form id="appearance-form" class="space-y-4">
<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">
Your permissions do not allow custom CSS changes.
Your permissions do not allow branding changes.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Product name</span>
<input id="brand-product-name" type="text" placeholder="Overlord" 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-fuchsia-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Navigation name</span>
<input id="brand-nav-name" type="text" placeholder="Overlord" 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-fuchsia-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Brand accent</span>
<input id="brand-accent-color" type="color" value="#7a5bff" class="mt-1 h-10 w-full px-2 py-1 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Icon class</span>
<input id="brand-icon-class" type="text" placeholder="fa-solid fa-crown" 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-fuchsia-500" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="block">
<span class="text-sm text-slate-300">Navigation logo URL</span>
<input id="brand-nav-logo-url" type="text" inputmode="url" placeholder="/assets/company-logo.png" 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-fuchsia-500" />
<div class="mt-2 flex items-center gap-2 min-w-0">
<input id="brand-nav-logo-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" class="sr-only" />
<label for="brand-nav-logo-file" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800 hover:bg-slate-700 border border-slate-700 text-xs text-slate-100 transition-colors cursor-pointer">
<i class="fa-solid fa-upload"></i>Upload image
</label>
<span id="brand-nav-logo-file-name" class="min-w-0 truncate text-xs text-slate-500">No file chosen</span>
</div>
</div>
<label class="block">
<span class="text-sm text-slate-300">Navigation logo alt text</span>
<input id="brand-nav-logo-alt" type="text" placeholder="Company logo" 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-fuchsia-500" />
</label>
<div class="block">
<span class="text-sm text-slate-300">Sign-in logo URL</span>
<input id="brand-login-logo-url" type="text" inputmode="url" placeholder="/assets/company-mark.png" 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-fuchsia-500" />
<div class="mt-2 flex items-center gap-2 min-w-0">
<input id="brand-login-logo-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" class="sr-only" />
<label for="brand-login-logo-file" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800 hover:bg-slate-700 border border-slate-700 text-xs text-slate-100 transition-colors cursor-pointer">
<i class="fa-solid fa-upload"></i>Upload image
</label>
<span id="brand-login-logo-file-name" class="min-w-0 truncate text-xs text-slate-500">No file chosen</span>
</div>
</div>
<label class="block">
<span class="text-sm text-slate-300">Sign-in logo alt text</span>
<input id="brand-login-logo-alt" type="text" placeholder="Company logo" 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-fuchsia-500" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Sign-in title</span>
<input id="brand-login-title" type="text" placeholder="Welcome back" 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-fuchsia-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Sign-in subtitle</span>
<input id="brand-login-subtitle" type="text" placeholder="Sign in to your control plane" 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-fuchsia-500" />
</label>
<div class="block">
<span class="text-sm text-slate-300">Sign-in background image URL</span>
<input id="brand-hero-image-url" type="text" inputmode="url" placeholder="/assets/login-hero.jpg" 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-fuchsia-500" />
<div class="mt-2 flex items-center gap-2 min-w-0">
<input id="brand-hero-image-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" class="sr-only" />
<label for="brand-hero-image-file" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800 hover:bg-slate-700 border border-slate-700 text-xs text-slate-100 transition-colors cursor-pointer">
<i class="fa-solid fa-upload"></i>Upload image
</label>
<span id="brand-hero-image-file-name" class="min-w-0 truncate text-xs text-slate-500">No file chosen</span>
</div>
</div>
<label class="block">
<span class="text-sm text-slate-300">Background image alt text</span>
<input id="brand-hero-image-alt" type="text" placeholder="Operations center" 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-fuchsia-500" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Footer text</span>
<input id="brand-footer-text" type="text" placeholder="Authorized access only" 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-fuchsia-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Support link text</span>
<input id="brand-support-text" type="text" placeholder="Need help?" 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-fuchsia-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Support URL</span>
<input id="brand-support-url" type="text" inputmode="url" placeholder="https://help.example.com" 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-fuchsia-500" />
</label>
</div>
<label class="block">
<span class="text-sm text-slate-300">Custom CSS</span>
<textarea
id="appearance-custom-css"
rows="12"
@@ -1253,7 +1373,7 @@
id="appearance-save-btn"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-fuchsia-700 hover:bg-fuchsia-600 transition-colors text-white text-sm font-medium"
>
<i class="fa-solid fa-paintbrush"></i>Save Custom CSS
<i class="fa-solid fa-floppy-disk"></i>Save Branding
</button>
</form>
</section>
+104 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import { extractTokenFromCookie, extractTokenFromHeader } from "./auth";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
import { getConfig, updateAppearanceConfig, type Config } from "./config";
import { generateTotpCode } from "./mfa";
import * as fs from "fs/promises";
import path from "path";
import {
createUser,
deleteUser,
@@ -9,11 +12,25 @@ import {
setUserMfaSecret,
} from "./users";
import { handleAuthRoutes } from "./server/routes/auth-routes";
import { handleMiscRoutes } from "./server/routes/misc-routes";
const PASSWORD = "Aa1!AuthMfaTest_2026";
const mockServer = {
requestIP: () => ({ address: "127.0.0.1" }),
};
let originalAppearance: Config["appearance"];
const uploadPublicRoot = path.resolve(".test-data", "branding-upload-public");
beforeAll(() => {
originalAppearance = JSON.parse(JSON.stringify(getConfig().appearance));
});
afterAll(async () => {
if (originalAppearance) {
await updateAppearanceConfig(originalAppearance.customCSS, originalAppearance.loginBranding);
}
await fs.rm(uploadPublicRoot, { recursive: true, force: true });
});
describe("auth token extraction", () => {
test("extractTokenFromHeader returns bearer token", () => {
@@ -39,8 +56,93 @@ describe("login branding", () => {
const body = (await res!.json()) as any;
expect(body.productName).toBe("Overlord");
expect(body.navName).toBe("Overlord");
expect(body.title).toBe("Welcome back");
expect(body.logoUrl).toBe("");
expect(body.navLogoUrl).toBe("");
expect(body.heroImageUrl).toBe("");
expect(body.accentColor).toBe("#7a5bff");
});
test("returns sanitized enterprise branding fields", async () => {
await updateAppearanceConfig("", {
productName: "Acme SOC",
navName: "Acme Console",
title: "Welcome to Acme",
subtitle: "Use your Acme identity",
iconClass: "fa-solid fa-shield-halved<script>",
logoUrl: "javascript:alert(1)",
navLogoUrl: "/assets/acme-nav.png",
heroImageUrl: "https://cdn.example.test/login.jpg",
accentColor: "#14B8A6",
footerText: "Authorized access only",
supportText: "Need help?",
supportUrl: "https://help.example.test",
});
const url = new URL("https://localhost/api/login/branding");
const res = await handleAuthRoutes(new Request(url), url, mockServer);
expect(res?.status).toBe(200);
const body = (await res!.json()) as any;
expect(body.productName).toBe("Acme SOC");
expect(body.navName).toBe("Acme Console");
expect(body.iconClass).toBe("fa-solid fa-shield-halvedscript");
expect(body.logoUrl).toBe("");
expect(body.navLogoUrl).toBe("/assets/acme-nav.png");
expect(body.heroImageUrl).toBe("https://cdn.example.test/login.jpg");
expect(body.accentColor).toBe("#14b8a6");
expect(body.footerText).toBe("Authorized access only");
expect(body.supportUrl).toBe("https://help.example.test/");
});
});
describe("branding uploads", () => {
test("accepts admin image upload and returns a branding asset URL", async () => {
const username = `branding_upload_${Date.now().toString(36)}`;
const created = await createUser(username, PASSWORD, "admin", "test");
expect(created.success).toBe(true);
try {
await fs.mkdir(uploadPublicRoot, { recursive: true });
const user = getUserById(created.userId!);
expect(user).toBeTruthy();
const token = await generateToken(user!);
const pngBytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d,
]);
const form = new FormData();
form.append("kind", "nav-logo");
form.append("file", new File([pngBytes], "logo.png", { type: "image/png" }));
const url = new URL("https://localhost/api/settings/appearance/image");
const res = await handleMiscRoutes(
new Request(url, {
method: "POST",
headers: { Cookie: `overlord_token=${token}` },
body: form,
}),
url,
{
CORS_HEADERS: {},
SERVER_VERSION: "test",
PUBLIC_ROOT: uploadPublicRoot,
requestIP: () => ({ address: "127.0.0.1" }),
getConsoleSessionCount: () => 0,
getRdSessionCount: () => 0,
getFileBrowserSessionCount: () => 0,
getProcessSessionCount: () => 0,
},
);
expect(res?.status).toBe(200);
const body = (await res!.json()) as any;
expect(body.url).toMatch(/^\/assets\/branding\/nav-logo-[a-z0-9]+-[0-9a-f-]+\.png$/);
expect(await Bun.file(path.join(uploadPublicRoot, body.url.replace("/assets/", "assets/"))).exists()).toBe(true);
} finally {
deleteUser(created.userId!);
}
});
});
+72 -1
View File
@@ -85,11 +85,20 @@ export interface Config {
customCSS: string;
loginBranding: {
productName: string;
navName: string;
title: string;
subtitle: string;
iconClass: string;
logoUrl: string;
logoAlt: string;
navLogoUrl: string;
navLogoAlt: string;
heroImageUrl: string;
heroImageAlt: string;
accentColor: string;
footerText: string;
supportText: string;
supportUrl: string;
};
};
plugins: {
@@ -205,11 +214,20 @@ const DEFAULT_CONFIG: Config = {
customCSS: "",
loginBranding: {
productName: "Overlord",
navName: "Overlord",
title: "Welcome back",
subtitle: "Sign in to your control plane",
iconClass: "fa-solid fa-crown",
logoUrl: "",
logoAlt: "Overlord logo",
navLogoUrl: "",
navLogoAlt: "Overlord logo",
heroImageUrl: "",
heroImageAlt: "Overlord sign-in background",
accentColor: "#7a5bff",
footerText: "",
supportText: "",
supportUrl: "",
},
},
plugins: {
@@ -304,18 +322,33 @@ function cleanLogoUrl(value: unknown): string {
return "";
}
function cleanBrandColor(value: unknown, fallback: string): string {
const raw = typeof value === "string" ? value.trim() : "";
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toLowerCase() : fallback;
}
function cleanLoginBranding(
value: Partial<Config["appearance"]["loginBranding"]> | undefined,
fallback: Config["appearance"]["loginBranding"],
): Config["appearance"]["loginBranding"] {
const productName = cleanText(value?.productName, fallback.productName, 80);
const navName = cleanText(value?.navName, productName, 80);
return {
productName,
navName,
title: cleanText(value?.title, fallback.title, 120),
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
iconClass: cleanIconClass(value?.iconClass, fallback.iconClass),
logoUrl: cleanLogoUrl(value?.logoUrl),
logoAlt: cleanText(value?.logoAlt, `${productName} logo`, 120),
navLogoUrl: cleanLogoUrl(value?.navLogoUrl),
navLogoAlt: cleanText(value?.navLogoAlt, `${navName} logo`, 120),
heroImageUrl: cleanLogoUrl(value?.heroImageUrl),
heroImageAlt: cleanText(value?.heroImageAlt, `${productName} sign-in image`, 160),
accentColor: cleanBrandColor(value?.accentColor, fallback.accentColor),
footerText: cleanText(value?.footerText, "", 160),
supportText: cleanText(value?.supportText, "", 120),
supportUrl: cleanLogoUrl(value?.supportUrl),
};
}
@@ -760,7 +793,12 @@ export function loadConfig(): Config {
...(fileConfig.appearance?.loginBranding || {}),
productName:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.productName,
navName:
process.env.OVERLORD_NAV_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.navName,
title:
process.env.OVERLORD_LOGIN_TITLE ||
fileConfig.appearance?.loginBranding?.title,
@@ -776,6 +814,32 @@ export function loadConfig(): Config {
logoAlt:
process.env.OVERLORD_LOGIN_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.logoAlt,
navLogoUrl:
process.env.OVERLORD_NAV_LOGO_URL ||
process.env.OVERLORD_BRAND_LOGO_URL ||
fileConfig.appearance?.loginBranding?.navLogoUrl,
navLogoAlt:
process.env.OVERLORD_NAV_LOGO_ALT ||
process.env.OVERLORD_BRAND_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.navLogoAlt,
heroImageUrl:
process.env.OVERLORD_LOGIN_HERO_IMAGE_URL ||
fileConfig.appearance?.loginBranding?.heroImageUrl,
heroImageAlt:
process.env.OVERLORD_LOGIN_HERO_IMAGE_ALT ||
fileConfig.appearance?.loginBranding?.heroImageAlt,
accentColor:
process.env.OVERLORD_BRAND_ACCENT_COLOR ||
fileConfig.appearance?.loginBranding?.accentColor,
footerText:
process.env.OVERLORD_LOGIN_FOOTER_TEXT ||
fileConfig.appearance?.loginBranding?.footerText,
supportText:
process.env.OVERLORD_LOGIN_SUPPORT_TEXT ||
fileConfig.appearance?.loginBranding?.supportText,
supportUrl:
process.env.OVERLORD_LOGIN_SUPPORT_URL ||
fileConfig.appearance?.loginBranding?.supportUrl,
},
DEFAULT_CONFIG.appearance.loginBranding,
),
@@ -1420,7 +1484,14 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
tls: process.env.OVERLORD_TLS_CERT || process.env.OVERLORD_TLS_CERTBOT_ENABLED,
auth: process.env.JWT_SECRET || process.env.OVERLORD_AGENT_TOKEN,
oidc: process.env.OVERLORD_OIDC_ENABLED || process.env.OVERLORD_OIDC_ISSUER || process.env.OVERLORD_OIDC_CLIENT_ID,
appearance: process.env.OVERLORD_LOGIN_BRAND_NAME || process.env.OVERLORD_LOGIN_LOGO_URL || process.env.OVERLORD_LOGIN_TITLE,
appearance:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
process.env.OVERLORD_NAV_LOGO_URL ||
process.env.OVERLORD_BRAND_LOGO_URL ||
process.env.OVERLORD_LOGIN_LOGO_URL ||
process.env.OVERLORD_LOGIN_HERO_IMAGE_URL ||
process.env.OVERLORD_LOGIN_TITLE,
};
if (data.notifications && typeof data.notifications === "object") {
+1
View File
@@ -551,6 +551,7 @@ async function startServer() {
misc: {
CORS_HEADERS,
SERVER_VERSION,
PUBLIC_ROOT,
getConsoleSessionCount: sessionManager.getConsoleSessionCount,
getRdSessionCount: sessionManager.getRdSessionCount,
getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount,
@@ -2,6 +2,8 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import path from "path";
import * as fs from "fs/promises";
import {
getClientMetricsSummary,
getClientMetricsSummaryForUser,
@@ -30,6 +32,7 @@ let inspectorRuntimePromise: Promise<any> | null | undefined;
type MiscRouteDeps = {
CORS_HEADERS: Record<string, string>;
SERVER_VERSION: string;
PUBLIC_ROOT: string;
requestIP?: (req: Request) => { address?: string } | null | undefined;
getConsoleSessionCount: () => number;
getRdSessionCount: () => number;
@@ -39,6 +42,29 @@ type MiscRouteDeps = {
tlsSource?: "certbot" | "configured" | "self-signed";
};
const BRAND_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
const BRAND_IMAGE_KINDS = new Set(["nav-logo", "login-logo", "hero-image"]);
function detectBrandImage(bytes: Uint8Array, contentType: string): { ext: string; type: string } | null {
const type = contentType.toLowerCase().split(";")[0].trim();
const isPng = bytes.length >= 8 &&
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
const isGif = bytes.length >= 6 &&
bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 &&
bytes[3] === 0x38 && (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61;
const isWebp = bytes.length >= 12 &&
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
if (isPng && (!type || type === "image/png")) return { ext: "png", type: "image/png" };
if (isJpeg && (!type || type === "image/jpeg" || type === "image/jpg")) return { ext: "jpg", type: "image/jpeg" };
if (isGif && (!type || type === "image/gif")) return { ext: "gif", type: "image/gif" };
if (isWebp && (!type || type === "image/webp")) return { ext: "webp", type: "image/webp" };
return null;
}
function maskOidcSettings() {
const oidc = getConfig().oidc;
return {
@@ -1246,6 +1272,69 @@ export async function handleMiscRoutes(
}
}
if (req.method === "POST" && url.pathname === "/api/settings/appearance/image") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:appearance");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ error: "Invalid upload form" }, { status: 400 });
}
const kind = String(form.get("kind") || "");
if (!BRAND_IMAGE_KINDS.has(kind)) {
return Response.json({ error: "Invalid branding image type" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) {
return Response.json({ error: "Missing image file" }, { status: 400 });
}
if (file.size <= 0) {
return Response.json({ error: "Image file is empty" }, { status: 400 });
}
if (file.size > BRAND_IMAGE_MAX_BYTES) {
return Response.json({ error: "Image exceeds 5 MB limit" }, { status: 400 });
}
const bytes = new Uint8Array(await file.arrayBuffer());
const image = detectBrandImage(bytes, file.type || "");
if (!image) {
return Response.json({ error: "Only PNG, JPEG, GIF, or WebP images are allowed" }, { status: 400 });
}
const uploadDir = path.join(deps.PUBLIC_ROOT, "assets", "branding");
await fs.mkdir(uploadDir, { recursive: true });
const filename = `${kind}-${Date.now().toString(36)}-${crypto.randomUUID()}.${image.ext}`;
const targetPath = path.join(uploadDir, filename);
await fs.writeFile(targetPath, bytes);
const assetUrl = `/assets/branding/${filename}`;
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Uploaded branding image (${kind}, ${bytes.length} bytes)`,
success: true,
});
return Response.json(
{ ok: true, url: assetUrl, contentType: image.type, size: bytes.length },
{ headers: deps.CORS_HEADERS },
);
}
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
const user = await authenticateRequest(req);
if (!user) {
+10 -1
View File
@@ -440,14 +440,23 @@ Self-hosted and enterprise deployments can brand the login screen with environme
```env
OVERLORD_LOGIN_BRAND_NAME=Acme SOC
OVERLORD_NAV_BRAND_NAME=Acme Console
OVERLORD_BRAND_ACCENT_COLOR=#14b8a6
OVERLORD_LOGIN_TITLE=Welcome to Acme Overlord
OVERLORD_LOGIN_SUBTITLE=Sign in with your Acme identity
OVERLORD_LOGIN_LOGO_URL=/assets/acme-logo.png
OVERLORD_LOGIN_LOGO_ALT=Acme logo
OVERLORD_NAV_LOGO_URL=/assets/acme-nav-logo.png
OVERLORD_NAV_LOGO_ALT=Acme logo
OVERLORD_LOGIN_HERO_IMAGE_URL=/assets/acme-login.jpg
OVERLORD_LOGIN_HERO_IMAGE_ALT=Acme operations center
OVERLORD_LOGIN_FOOTER_TEXT=Authorized Acme access only
OVERLORD_LOGIN_SUPPORT_TEXT=Need access help?
OVERLORD_LOGIN_SUPPORT_URL=https://help.example.com
OVERLORD_LOGIN_ICON_CLASS=fa-solid fa-shield-halved
```
`OVERLORD_LOGIN_LOGO_URL` accepts absolute `http(s)` URLs or root-relative paths. If no logo URL is set, the login screen uses `OVERLORD_LOGIN_ICON_CLASS`.
Logo, hero, and support URLs accept absolute `http(s)` URLs or root-relative paths. If no logo URL is set, the UI uses `OVERLORD_LOGIN_ICON_CLASS`. The same options can also be managed from Settings → Branding.
---