custom branding
This commit is contained in:
@@ -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 {}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user