From b29d5b4291cb7bf8ce0f6b6483cdab5235a033d3 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 8 Jul 2026 21:47:57 -0500 Subject: [PATCH] custom branding --- .env.example | 9 + Overlord-Server/public/assets/login.js | 32 ++++ Overlord-Server/public/assets/main.css | 76 +++++++- Overlord-Server/public/assets/nav.js | 42 +++++ Overlord-Server/public/assets/nav/template.js | 18 +- Overlord-Server/public/assets/settings.js | 170 +++++++++++++++++- Overlord-Server/public/login.html | 3 + Overlord-Server/public/settings.html | 136 +++++++++++++- Overlord-Server/src/auth.test.ts | 106 ++++++++++- Overlord-Server/src/config.ts | 73 +++++++- Overlord-Server/src/main-server.ts | 1 + .../src/server/routes/misc-routes.ts | 89 +++++++++ README.md | 11 +- 13 files changed, 735 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index efa2617..78cb2d3 100644 --- a/.env.example +++ b/.env.example @@ -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 # ============================================ diff --git a/Overlord-Server/public/assets/login.js b/Overlord-Server/public/assets/login.js index 927354b..bd795c9 100644 --- a/Overlord-Server/public/assets/login.js +++ b/Overlord-Server/public/assets/login.js @@ -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 {} })(); diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 5278126..7d63f92 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -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; diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index f953bc6..002ce4b 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -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 {} +} diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index 5ce63dd..a574a07 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -133,8 +133,10 @@ function mountTopbar(host) { host.innerHTML = `
- - Overlord + + + + Overlord - - Overlord + + + Overlord `; host.insertAdjacentElement("afterend", mobileBar); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 2325792..f29a307 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -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); diff --git a/Overlord-Server/public/login.html b/Overlord-Server/public/login.html index bac245a..0f8f8ce 100644 --- a/Overlord-Server/public/login.html +++ b/Overlord-Server/public/login.html @@ -14,6 +14,7 @@ + + +
diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 2df83a3..316aaf5 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -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; + } + } @@ -100,7 +126,7 @@ Screenshot Thumbnails Input Archive Admin Build Rate Limit - Custom CSS + Branding Export / Import Server Health @@ -1220,24 +1246,118 @@ - +