mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
feat(branding): full white-label expansion of branding tab
Add console wallpaper, login page branding (title/subtitle/background/footer), global agent download page branding, footer/copyright with 'Powered by BetterDesk' toggle, and custom CSS injection. Includes background image upload route (8MB), sanitization for colors/gradients/CSS, dynamic theme CSS for app and login pages, sidebar attribution, and EN/PL/ZH i18n keys. This commit was made possible thanks to Insolve.
This commit is contained in:
+45
-6
@@ -853,13 +853,8 @@
|
||||
"record": "Record Screen",
|
||||
"view_only": "View Only",
|
||||
"pin_toolbar": "Pin Toolbar",
|
||||
"move_toolbar": "Move Toolbar",
|
||||
"toggle_menu": "Show/Hide Toolbar",
|
||||
"monitors": "Monitors",
|
||||
"select_monitor": "Select Monitor",
|
||||
"virtual_displays": "Virtual displays",
|
||||
"virtual_display": "Virtual display",
|
||||
"plug_out_all": "Plug out all",
|
||||
"quality_speed": "Speed (Low Quality, 60fps)",
|
||||
"quality_quality": "Quality (Best, 30fps)",
|
||||
"recording_started": "Recording started",
|
||||
@@ -956,7 +951,51 @@
|
||||
"imported": "Theme imported successfully. Reloading...",
|
||||
"import_error": "Failed to import theme. Invalid file format.",
|
||||
"reset_confirm": "Are you sure you want to reset branding to defaults? This cannot be undone.",
|
||||
"reset_success": "Branding reset to defaults. Reloading..."
|
||||
"reset_success": "Branding reset to defaults. Reloading...",
|
||||
"powered_by": "Powered by",
|
||||
"background_title": "Background & Appearance",
|
||||
"background_desc": "Set a console wallpaper, gradient or solid background. Cards float above it.",
|
||||
"bg_type_none": "None",
|
||||
"bg_type_color": "Solid Color",
|
||||
"bg_type_gradient": "Gradient",
|
||||
"bg_type_image": "Image",
|
||||
"bg_color": "Background Color",
|
||||
"bg_gradient": "CSS Gradient",
|
||||
"bg_gradient_hint": "Any valid CSS gradient, e.g. linear-gradient(135deg, #1e293b, #0f172a)",
|
||||
"bg_image_upload": "Upload Background Image",
|
||||
"bg_image_choose": "Choose image file",
|
||||
"bg_image_hint": "PNG, JPG, WebP or GIF. Max 8 MB. Stored on the server.",
|
||||
"bg_image_url": "Image Path / URL",
|
||||
"bg_size": "Image Sizing",
|
||||
"bg_size_cover": "Cover (fill screen)",
|
||||
"bg_size_contain": "Contain (fit)",
|
||||
"bg_size_auto": "Auto (original)",
|
||||
"bg_blur": "Background Blur",
|
||||
"bg_overlay": "Dark Overlay",
|
||||
"bg_overlay_hint": "Darkens the background for better text readability.",
|
||||
"bg_image_too_large": "Image is too large. Maximum size is 8 MB.",
|
||||
"bg_image_invalid_type": "Invalid file type. Use PNG, JPG, WebP or GIF.",
|
||||
"bg_upload_success": "Background image uploaded successfully.",
|
||||
"login_title": "Login Page",
|
||||
"login_desc": "Customize the login screen heading, subtitle and background.",
|
||||
"login_heading": "Login Heading",
|
||||
"login_heading_placeholder": "Welcome back",
|
||||
"login_subheading": "Login Subtitle",
|
||||
"login_subheading_placeholder": "Sign in to your account",
|
||||
"login_bg_inherit": "Same as console",
|
||||
"agent_title": "Agent Download Page",
|
||||
"agent_desc": "Customize the public agent download portal shown to end users.",
|
||||
"agent_show_powered": "Show \"Powered by BetterDesk\"",
|
||||
"agent_show_powered_hint": "Display the BetterDesk attribution in the download portal footer.",
|
||||
"footer_title": "Footer & Copyright",
|
||||
"footer_desc": "Add a custom footer line shown on the login screen and console sidebar.",
|
||||
"footer_text": "Footer Text",
|
||||
"footer_text_hint": "e.g. a copyright notice or company name.",
|
||||
"show_powered": "Show \"BetterDesk\" attribution",
|
||||
"show_powered_hint": "Display the BetterDesk name and version in the login footer and sidebar.",
|
||||
"custom_css_title": "Custom CSS",
|
||||
"custom_css_desc": "Advanced: inject custom CSS applied across the console and login page.",
|
||||
"custom_css_hint": "Use with care. Invalid or unsafe rules are sanitized. Max 20,000 characters."
|
||||
},
|
||||
"device_detail": {
|
||||
"tab_overview": "Overview",
|
||||
|
||||
+45
-6
@@ -853,13 +853,8 @@
|
||||
"record": "Nagrywaj ekran",
|
||||
"view_only": "Tylko podgląd",
|
||||
"pin_toolbar": "Przypnij pasek",
|
||||
"move_toolbar": "Przesuń pasek",
|
||||
"toggle_menu": "Pokaż/ukryj pasek",
|
||||
"monitors": "Monitory",
|
||||
"select_monitor": "Wybierz monitor",
|
||||
"virtual_displays": "Wirtualne ekrany",
|
||||
"virtual_display": "Wirtualny ekran",
|
||||
"plug_out_all": "Odłącz wszystkie",
|
||||
"quality_speed": "Szybkość (niska jakość, 60fps)",
|
||||
"quality_quality": "Jakość (najlepsza, 30fps)",
|
||||
"recording_started": "Nagrywanie rozpoczęte",
|
||||
@@ -956,7 +951,51 @@
|
||||
"imported": "Motyw zaimportowany pomyślnie. Przeładowywanie...",
|
||||
"import_error": "Nie udało się zaimportować motywu. Nieprawidłowy format pliku.",
|
||||
"reset_confirm": "Czy na pewno chcesz przywrócić domyślny wygląd? Tej operacji nie można cofnąć.",
|
||||
"reset_success": "Wygląd przywrócony do domyślnych. Przeładowywanie..."
|
||||
"reset_success": "Wygląd przywrócony do domyślnych. Przeładowywanie...",
|
||||
"powered_by": "Napędzane przez",
|
||||
"background_title": "Tło i wygląd",
|
||||
"background_desc": "Ustaw tapetę konsoli, gradient lub jednolite tło. Karty unoszą się nad nim.",
|
||||
"bg_type_none": "Brak",
|
||||
"bg_type_color": "Jednolity kolor",
|
||||
"bg_type_gradient": "Gradient",
|
||||
"bg_type_image": "Obraz",
|
||||
"bg_color": "Kolor tła",
|
||||
"bg_gradient": "Gradient CSS",
|
||||
"bg_gradient_hint": "Dowolny prawidłowy gradient CSS, np. linear-gradient(135deg, #1e293b, #0f172a)",
|
||||
"bg_image_upload": "Prześlij obraz tła",
|
||||
"bg_image_choose": "Wybierz plik obrazu",
|
||||
"bg_image_hint": "PNG, JPG, WebP lub GIF. Maks. 8 MB. Zapisywane na serwerze.",
|
||||
"bg_image_url": "Ścieżka / URL obrazu",
|
||||
"bg_size": "Skalowanie obrazu",
|
||||
"bg_size_cover": "Wypełnij (cały ekran)",
|
||||
"bg_size_contain": "Dopasuj (zmieść)",
|
||||
"bg_size_auto": "Auto (oryginał)",
|
||||
"bg_blur": "Rozmycie tła",
|
||||
"bg_overlay": "Ciemna nakładka",
|
||||
"bg_overlay_hint": "Przyciemnia tło dla lepszej czytelności tekstu.",
|
||||
"bg_image_too_large": "Obraz jest za duży. Maksymalny rozmiar to 8 MB.",
|
||||
"bg_image_invalid_type": "Nieprawidłowy typ pliku. Użyj PNG, JPG, WebP lub GIF.",
|
||||
"bg_upload_success": "Obraz tła przesłany pomyślnie.",
|
||||
"login_title": "Strona logowania",
|
||||
"login_desc": "Dostosuj nagłówek, podtytuł i tło ekranu logowania.",
|
||||
"login_heading": "Nagłówek logowania",
|
||||
"login_heading_placeholder": "Witaj ponownie",
|
||||
"login_subheading": "Podtytuł logowania",
|
||||
"login_subheading_placeholder": "Zaloguj się do swojego konta",
|
||||
"login_bg_inherit": "Tak jak konsola",
|
||||
"agent_title": "Strona pobierania agenta",
|
||||
"agent_desc": "Dostosuj publiczny portal pobierania agenta widoczny dla użytkowników.",
|
||||
"agent_show_powered": "Pokaż \"Powered by BetterDesk\"",
|
||||
"agent_show_powered_hint": "Wyświetla informację o BetterDesk w stopce portalu pobierania.",
|
||||
"footer_title": "Stopka i prawa autorskie",
|
||||
"footer_desc": "Dodaj własny wiersz stopki widoczny na ekranie logowania i w panelu bocznym.",
|
||||
"footer_text": "Tekst stopki",
|
||||
"footer_text_hint": "np. informacja o prawach autorskich lub nazwa firmy.",
|
||||
"show_powered": "Pokaż informację \"BetterDesk\"",
|
||||
"show_powered_hint": "Wyświetla nazwę i wersję BetterDesk w stopce logowania i panelu bocznym.",
|
||||
"custom_css_title": "Własny CSS",
|
||||
"custom_css_desc": "Zaawansowane: wstrzyknij własny CSS stosowany w całej konsoli i na stronie logowania.",
|
||||
"custom_css_hint": "Używaj ostrożnie. Nieprawidłowe lub niebezpieczne reguły są filtrowane. Maks. 20 000 znaków."
|
||||
},
|
||||
"device_detail": {
|
||||
"tab_overview": "Przegląd",
|
||||
|
||||
+45
-6
@@ -839,13 +839,8 @@
|
||||
"record": "录制屏幕",
|
||||
"view_only": "仅查看",
|
||||
"pin_toolbar": "固定工具栏",
|
||||
"move_toolbar": "移动工具栏",
|
||||
"toggle_menu": "展开/折叠菜单",
|
||||
"monitors": "显示器",
|
||||
"select_monitor": "选择显示器",
|
||||
"virtual_displays": "虚拟显示器",
|
||||
"virtual_display": "虚拟显示器",
|
||||
"plug_out_all": "全部拔出",
|
||||
"quality_speed": "速度(低质量,60fps)",
|
||||
"quality_quality": "质量(最佳,30fps)",
|
||||
"recording_started": "录制已开始",
|
||||
@@ -946,7 +941,51 @@
|
||||
"imported": "主题导入成功。正在重新加载...",
|
||||
"import_error": "主题导入失败。文件格式无效。",
|
||||
"reset_confirm": "确定要将品牌设置恢复为默认值吗?此操作无法撤销。",
|
||||
"reset_success": "品牌设置已恢复为默认值。正在重新加载..."
|
||||
"reset_success": "品牌设置已恢复为默认值。正在重新加载...",
|
||||
"powered_by": "技术支持",
|
||||
"background_title": "背景与外观",
|
||||
"background_desc": "设置控制台壁纸、渐变或纯色背景。卡片悬浮其上。",
|
||||
"bg_type_none": "无",
|
||||
"bg_type_color": "纯色",
|
||||
"bg_type_gradient": "渐变",
|
||||
"bg_type_image": "图片",
|
||||
"bg_color": "背景颜色",
|
||||
"bg_gradient": "CSS 渐变",
|
||||
"bg_gradient_hint": "任何有效的 CSS 渐变,例如 linear-gradient(135deg, #1e293b, #0f172a)",
|
||||
"bg_image_upload": "上传背景图片",
|
||||
"bg_image_choose": "选择图片文件",
|
||||
"bg_image_hint": "PNG、JPG、WebP 或 GIF。最大 8 MB。存储在服务器上。",
|
||||
"bg_image_url": "图片路径 / URL",
|
||||
"bg_size": "图片尺寸",
|
||||
"bg_size_cover": "覆盖(填满屏幕)",
|
||||
"bg_size_contain": "包含(适应)",
|
||||
"bg_size_auto": "自动(原始)",
|
||||
"bg_blur": "背景模糊",
|
||||
"bg_overlay": "深色遮罩",
|
||||
"bg_overlay_hint": "使背景变暗以提高文字可读性。",
|
||||
"bg_image_too_large": "图片过大。最大尺寸为 8 MB。",
|
||||
"bg_image_invalid_type": "文件类型无效。请使用 PNG、JPG、WebP 或 GIF。",
|
||||
"bg_upload_success": "背景图片上传成功。",
|
||||
"login_title": "登录页面",
|
||||
"login_desc": "自定义登录界面的标题、副标题和背景。",
|
||||
"login_heading": "登录标题",
|
||||
"login_heading_placeholder": "欢迎回来",
|
||||
"login_subheading": "登录副标题",
|
||||
"login_subheading_placeholder": "登录您的账户",
|
||||
"login_bg_inherit": "与控制台相同",
|
||||
"agent_title": "代理下载页面",
|
||||
"agent_desc": "自定义向最终用户显示的公共代理下载门户。",
|
||||
"agent_show_powered": "显示 \"Powered by BetterDesk\"",
|
||||
"agent_show_powered_hint": "在下载门户页脚显示 BetterDesk 署名。",
|
||||
"footer_title": "页脚与版权",
|
||||
"footer_desc": "添加显示在登录界面和控制台侧边栏的自定义页脚行。",
|
||||
"footer_text": "页脚文字",
|
||||
"footer_text_hint": "例如版权声明或公司名称。",
|
||||
"show_powered": "显示 \"BetterDesk\" 署名",
|
||||
"show_powered_hint": "在登录页脚和侧边栏显示 BetterDesk 名称和版本。",
|
||||
"custom_css_title": "自定义 CSS",
|
||||
"custom_css_desc": "高级:注入应用于整个控制台和登录页面的自定义 CSS。",
|
||||
"custom_css_hint": "请谨慎使用。无效或不安全的规则会被过滤。最多 20,000 个字符。"
|
||||
},
|
||||
"device_detail": {
|
||||
"tab_overview": "概览",
|
||||
|
||||
@@ -428,6 +428,32 @@ table {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.sidebar-attribution {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-xs) var(--space-sm) 0;
|
||||
margin-top: var(--space-xs);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-attribution-text {
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sidebar-attribution-powered {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Hide attribution in collapsed rail to avoid cramped layout */
|
||||
#sidebar[data-collapsed="true"] .sidebar-attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -489,6 +489,7 @@
|
||||
initLogoTypeSelector();
|
||||
initColorPickers();
|
||||
initFontPickers();
|
||||
initBackgroundSelectors();
|
||||
initBrandingActions();
|
||||
|
||||
} catch (error) {
|
||||
@@ -544,10 +545,163 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Background & appearance (console)
|
||||
const setVal = (id, val) => { const el = document.getElementById(id); if (el != null && el) el.value = val; };
|
||||
const setChecked = (id, val) => { const el = document.getElementById(id); if (el) el.checked = val; };
|
||||
const checkRadio = (name, val) => {
|
||||
const r = document.querySelector(`input[name="${name}"][value="${val}"]`);
|
||||
if (r) r.checked = true;
|
||||
};
|
||||
checkRadio('bg-type', data.bgType || 'none');
|
||||
setVal('bg-color', data.bgColor || '');
|
||||
setVal('bg-gradient', data.bgGradient || '');
|
||||
setVal('bg-image-url', data.bgImageUrl || '');
|
||||
setVal('bg-blur', data.bgBlur || '0');
|
||||
setVal('bg-overlay', data.bgOverlay || '0');
|
||||
setVal('bg-size', data.bgSize || 'cover');
|
||||
if (data.bgColor && document.getElementById('bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.bgColor)) {
|
||||
document.getElementById('bg-color-picker').value = data.bgColor;
|
||||
}
|
||||
const bgBlurVal = document.getElementById('bg-blur-value');
|
||||
if (bgBlurVal) bgBlurVal.textContent = data.bgBlur || '0';
|
||||
const bgOverlayVal = document.getElementById('bg-overlay-value');
|
||||
if (bgOverlayVal) bgOverlayVal.textContent = data.bgOverlay || '0';
|
||||
showBackgroundPanel('bg', data.bgType || 'none');
|
||||
|
||||
// Login page
|
||||
setVal('login-title', data.loginTitle || '');
|
||||
setVal('login-subtitle', data.loginSubtitle || '');
|
||||
checkRadio('login-bg-type', data.loginBgType || 'inherit');
|
||||
setVal('login-bg-color', data.loginBgColor || '');
|
||||
setVal('login-bg-gradient', data.loginBgGradient || '');
|
||||
setVal('login-bg-image-url', data.loginBgImageUrl || '');
|
||||
setVal('login-bg-overlay', data.loginBgOverlay || '0');
|
||||
if (data.loginBgColor && document.getElementById('login-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.loginBgColor)) {
|
||||
document.getElementById('login-bg-color-picker').value = data.loginBgColor;
|
||||
}
|
||||
const loginOverlayVal = document.getElementById('login-bg-overlay-value');
|
||||
if (loginOverlayVal) loginOverlayVal.textContent = data.loginBgOverlay || '0';
|
||||
showBackgroundPanel('login-bg', data.loginBgType || 'inherit');
|
||||
|
||||
// Agent download page
|
||||
checkRadio('agent-bg-type', data.agentBgType || 'none');
|
||||
setVal('agent-bg-color', data.agentBgColor || '');
|
||||
setVal('agent-bg-gradient', data.agentBgGradient || '');
|
||||
setVal('agent-bg-image-url', data.agentBgImageUrl || '');
|
||||
setChecked('agent-show-powered', data.agentShowPoweredBy !== 'false');
|
||||
if (data.agentBgColor && document.getElementById('agent-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.agentBgColor)) {
|
||||
document.getElementById('agent-bg-color-picker').value = data.agentBgColor;
|
||||
}
|
||||
showBackgroundPanel('agent-bg', data.agentBgType || 'none');
|
||||
|
||||
// Footer & custom CSS
|
||||
setVal('footer-text', data.footerText || '');
|
||||
setChecked('show-powered', data.showPoweredBy !== 'false');
|
||||
setVal('custom-css', data.customCss || '');
|
||||
|
||||
// Update preview
|
||||
updateLogoPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the active panel for a background group (bg / login-bg / agent-bg)
|
||||
* and hide the rest. "none"/"inherit" hide all detail panels.
|
||||
*/
|
||||
function showBackgroundPanel(prefix, type) {
|
||||
['none', 'inherit', 'color', 'gradient', 'image'].forEach(t => {
|
||||
const panel = document.getElementById(`${prefix}-${t}-panel`);
|
||||
if (panel) panel.classList.add('hidden');
|
||||
});
|
||||
const active = document.getElementById(`${prefix}-${type}-panel`);
|
||||
if (active) active.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire background selectors: radio toggles, range value labels,
|
||||
* color picker sync and image uploads for console/login/agent groups.
|
||||
*/
|
||||
function initBackgroundSelectors() {
|
||||
// Radio toggles for the three background groups
|
||||
[['bg-type', 'bg'], ['login-bg-type', 'login-bg'], ['agent-bg-type', 'agent-bg']].forEach(([name, prefix]) => {
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach(radio => {
|
||||
radio.addEventListener('change', () => showBackgroundPanel(prefix, radio.value));
|
||||
});
|
||||
});
|
||||
|
||||
// Color picker <-> hex text sync for the standalone background pickers
|
||||
[['bg-color-picker', 'bg-color'], ['login-bg-color-picker', 'login-bg-color'], ['agent-bg-color-picker', 'agent-bg-color']].forEach(([pickerId, textId]) => {
|
||||
const picker = document.getElementById(pickerId);
|
||||
const text = document.getElementById(textId);
|
||||
if (picker && text) {
|
||||
picker.addEventListener('input', () => { text.value = picker.value; });
|
||||
text.addEventListener('input', () => {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(text.value)) picker.value = text.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Range value labels
|
||||
[['bg-blur', 'bg-blur-value'], ['bg-overlay', 'bg-overlay-value'], ['login-bg-overlay', 'login-bg-overlay-value']].forEach(([rangeId, labelId]) => {
|
||||
const range = document.getElementById(rangeId);
|
||||
const label = document.getElementById(labelId);
|
||||
if (range && label) {
|
||||
range.addEventListener('input', () => { label.textContent = range.value; });
|
||||
}
|
||||
});
|
||||
|
||||
// Background image uploads (shared route, data-target picks the URL field)
|
||||
document.querySelectorAll('.branding-bg-file').forEach(input => {
|
||||
input.addEventListener('change', handleBackgroundFileUpload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle background image upload — uploads to the shared background route
|
||||
* and fills the URL field referenced by the input's data-target attribute.
|
||||
*/
|
||||
async function handleBackgroundFileUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const maxSize = 8 * 1024 * 1024; // 8 MB
|
||||
if (file.size > maxSize) {
|
||||
Utils.showNotification(_('branding.bg_image_too_large'), 'error');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
Utils.showNotification(_('branding.bg_image_invalid_type'), 'error');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = e.target.dataset.target;
|
||||
const nameElId = e.target.id.replace('-file', '-file-name');
|
||||
const nameEl = document.getElementById(nameElId);
|
||||
if (nameEl) nameEl.textContent = file.name;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('background', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/settings/branding/upload-background', {
|
||||
method: 'POST',
|
||||
headers: { 'x-csrf-token': window.BetterDesk?.csrfToken || '' },
|
||||
body: formData
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
const urlInput = document.getElementById(targetId);
|
||||
if (urlInput) urlInput.value = result.url;
|
||||
Notifications.success(_('branding.bg_upload_success'));
|
||||
} catch (err) {
|
||||
Utils.showNotification(err.message || _('errors.server_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize logo type selector
|
||||
*/
|
||||
@@ -765,6 +919,36 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Background & appearance (console)
|
||||
data.bgType = document.querySelector('input[name="bg-type"]:checked')?.value || 'none';
|
||||
data.bgColor = document.getElementById('bg-color')?.value.trim() || '';
|
||||
data.bgGradient = document.getElementById('bg-gradient')?.value.trim() || '';
|
||||
data.bgImageUrl = document.getElementById('bg-image-url')?.value.trim() || '';
|
||||
data.bgBlur = document.getElementById('bg-blur')?.value || '';
|
||||
data.bgOverlay = document.getElementById('bg-overlay')?.value || '';
|
||||
data.bgSize = document.getElementById('bg-size')?.value || 'cover';
|
||||
|
||||
// Login page
|
||||
data.loginBgType = document.querySelector('input[name="login-bg-type"]:checked')?.value || 'inherit';
|
||||
data.loginBgColor = document.getElementById('login-bg-color')?.value.trim() || '';
|
||||
data.loginBgGradient = document.getElementById('login-bg-gradient')?.value.trim() || '';
|
||||
data.loginBgImageUrl = document.getElementById('login-bg-image-url')?.value.trim() || '';
|
||||
data.loginBgOverlay = document.getElementById('login-bg-overlay')?.value || '';
|
||||
data.loginTitle = document.getElementById('login-title')?.value.trim() || '';
|
||||
data.loginSubtitle = document.getElementById('login-subtitle')?.value.trim() || '';
|
||||
|
||||
// Agent download page
|
||||
data.agentBgType = document.querySelector('input[name="agent-bg-type"]:checked')?.value || 'none';
|
||||
data.agentBgColor = document.getElementById('agent-bg-color')?.value.trim() || '';
|
||||
data.agentBgGradient = document.getElementById('agent-bg-gradient')?.value.trim() || '';
|
||||
data.agentBgImageUrl = document.getElementById('agent-bg-image-url')?.value.trim() || '';
|
||||
data.agentShowPoweredBy = document.getElementById('agent-show-powered')?.checked ? 'true' : 'false';
|
||||
|
||||
// Footer & custom CSS
|
||||
data.footerText = document.getElementById('footer-text')?.value.trim() || '';
|
||||
data.showPoweredBy = document.getElementById('show-powered')?.checked ? 'true' : 'false';
|
||||
data.customCss = document.getElementById('custom-css')?.value || '';
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const bundleService = require('../services/agentBundleService');
|
||||
const buildWorker = require('../services/agentBuildWorker');
|
||||
const db = require('../services/database');
|
||||
const config = require('../config/config');
|
||||
const brandingService = require('../services/brandingService');
|
||||
|
||||
// Branding payloads may carry a base64-encoded logo up to 10 MB; expand the
|
||||
// default 2 MB JSON body limit on the bundle CRUD + preview endpoints only.
|
||||
@@ -238,9 +239,19 @@ router.get('/d/:bundleId', async (req, res) => {
|
||||
...p,
|
||||
status: buildMap[`${p.platform}/${p.arch}/${p.format}`] || 'pending',
|
||||
}));
|
||||
// Global console branding provides portal-wide defaults (wallpaper,
|
||||
// attribution) shared by every bundle that does not override them.
|
||||
const gb = brandingService.getBranding();
|
||||
const globalBranding = {
|
||||
background: brandingService.buildBackgroundValue(gb.agentBgType, gb.agentBgColor, gb.agentBgGradient, gb.agentBgImageUrl),
|
||||
showPoweredBy: gb.agentShowPoweredBy !== 'false',
|
||||
appName: gb.appName || 'BetterDesk',
|
||||
logoUrl: gb.logoType === 'image' ? gb.logoUrl : '',
|
||||
};
|
||||
res.render('agent-download', {
|
||||
bundle,
|
||||
platforms,
|
||||
globalBranding,
|
||||
t: req.t.bind(req),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -247,6 +247,49 @@ router.post('/api/settings/branding/upload-logo', requireAuth, requirePermission
|
||||
});
|
||||
});
|
||||
|
||||
// ── Background image upload (disk storage) ───────────────────────────────────
|
||||
const bgUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const hash = crypto.randomBytes(8).toString('hex');
|
||||
cb(null, `bg-${hash}${ext}`);
|
||||
}
|
||||
}),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = /^image\/(png|jpeg|gif|webp|svg\+xml)$/;
|
||||
if (allowed.test(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/branding/upload-background - Upload a background image.
|
||||
* Used by the console / login / agent-portal wallpaper pickers. Returns the
|
||||
* served URL; the caller decides which branding field to assign it to. Old
|
||||
* background files are not auto-removed because a single image may be shared
|
||||
* across console/login/agent backgrounds.
|
||||
*/
|
||||
router.post('/api/settings/branding/upload-background', requireAuth, requirePermission('branding.edit'), (req, res) => {
|
||||
bgUpload.single('background')(req, res, async (err) => {
|
||||
if (err) {
|
||||
const msg = err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 8 MB)' : (err.message || 'Upload failed');
|
||||
return res.status(400).json({ success: false, error: msg });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: 'No file provided' });
|
||||
}
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
await db.logAction(req.session?.userId, 'branding_bg_upload', `Uploaded background: ${req.file.filename}`, req.ip);
|
||||
res.json({ success: true, url });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/branding/reset - Reset branding to defaults (admin only)
|
||||
*/
|
||||
|
||||
@@ -86,6 +86,77 @@ function validateBrandingUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a free-form CSS color or gradient value used in generated stylesheets.
|
||||
* Allows only a safe character set so the value cannot break out of its CSS
|
||||
* declaration or inject additional rules. Strips characters that could be used
|
||||
* for CSS injection (`{`, `}`, `;`, `<`, `>`, `@`, backslash, quotes) and
|
||||
* neutralizes `expression(` / `javascript:` / `url(` patterns.
|
||||
* @param {string} value
|
||||
* @returns {string} sanitized value (may be empty)
|
||||
*/
|
||||
function sanitizeCssColorValue(value) {
|
||||
if (value === undefined || value === null) return '';
|
||||
let v = String(value).trim();
|
||||
if (v === '') return '';
|
||||
// Drop anything outside a conservative allowlist for colors/gradients.
|
||||
v = v.replace(/[^a-zA-Z0-9#%.,()\s/-]/g, '');
|
||||
// Defuse function-based CSS abuse even within the allowed charset.
|
||||
v = v.replace(/expression\s*\(/gi, '');
|
||||
v = v.replace(/url\s*\(/gi, '');
|
||||
return v.substring(0, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CSS background shorthand value from a configured background spec.
|
||||
* @param {string} type - 'color' | 'gradient' | 'image' | anything else => ''
|
||||
* @param {string} color
|
||||
* @param {string} gradient
|
||||
* @param {string} imageUrl - already validated via validateBrandingUrl
|
||||
* @returns {string} CSS background value or '' when nothing is configured
|
||||
*/
|
||||
function buildBackgroundValue(type, color, gradient, imageUrl) {
|
||||
switch (type) {
|
||||
case 'color':
|
||||
return sanitizeCssColorValue(color);
|
||||
case 'gradient':
|
||||
return sanitizeCssColorValue(gradient);
|
||||
case 'image': {
|
||||
const url = validateBrandingUrl(imageUrl);
|
||||
if (!url) return '';
|
||||
// Encode quotes/parens defensively even though validation already ran.
|
||||
const safe = url.replace(/["()\\]/g, encodeURIComponent);
|
||||
return `url("${safe}")`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize operator-supplied custom CSS (advanced escape hatch).
|
||||
* Removes constructs that could lead to XSS / data exfiltration:
|
||||
* - </style> breakouts, HTML tags
|
||||
* - @import (external stylesheet loading)
|
||||
* - expression() (legacy IE script execution)
|
||||
* - behavior: / -moz-binding: (HTC / XBL script binding)
|
||||
* - url(javascript:|data:|vbscript:) schemes
|
||||
* @param {string} value
|
||||
* @returns {string} sanitized CSS (capped length)
|
||||
*/
|
||||
function sanitizeCustomCss(value) {
|
||||
if (value === undefined || value === null) return '';
|
||||
let css = String(value);
|
||||
css = css.replace(/<\s*\/?\s*style[^>]*>/gi, ''); // </style> breakout
|
||||
css = css.replace(/<[^>]*>/g, ''); // any HTML tags
|
||||
css = css.replace(/@import\b[^;]*;?/gi, ''); // external imports
|
||||
css = css.replace(/expression\s*\(/gi, ''); // IE expression()
|
||||
css = css.replace(/(?:-\w+-)?behavior\s*:/gi, ''); // HTC/XBL binding
|
||||
css = css.replace(/-moz-binding\s*:/gi, ''); // Firefox XBL binding
|
||||
css = css.replace(/url\s*\(\s*["']?\s*(?:javascript|data|vbscript|file):[^)]*\)/gi, 'none');
|
||||
return css.substring(0, 20000);
|
||||
}
|
||||
|
||||
// Default branding (BetterDesk original theme)
|
||||
const DEFAULT_BRANDING = {
|
||||
// Brand identity
|
||||
@@ -106,7 +177,39 @@ const DEFAULT_BRANDING = {
|
||||
|
||||
// Favicon (SVG)
|
||||
faviconSvg: '', // Custom favicon SVG (empty = default)
|
||||
|
||||
|
||||
// Console background & appearance
|
||||
bgType: 'none', // 'none' | 'color' | 'gradient' | 'image'
|
||||
bgColor: '', // solid color (when bgType === 'color')
|
||||
bgGradient: '', // CSS gradient (when bgType === 'gradient')
|
||||
bgImageUrl: '', // uploaded/linked image (when bgType === 'image')
|
||||
bgBlur: '', // blur radius in px applied to the wallpaper layer
|
||||
bgOverlay: '', // dark overlay opacity 0-100 (%) for readability
|
||||
bgSize: 'cover', // 'cover' | 'contain' | 'repeat' | 'center'
|
||||
|
||||
// Login page branding
|
||||
loginBgType: 'inherit', // 'inherit' | 'none' | 'color' | 'gradient' | 'image'
|
||||
loginBgColor: '',
|
||||
loginBgGradient: '',
|
||||
loginBgImageUrl: '',
|
||||
loginBgOverlay: '', // dark overlay opacity 0-100 (%)
|
||||
loginTitle: '', // overrides the login heading
|
||||
loginSubtitle: '', // overrides the login subtitle
|
||||
|
||||
// Footer / attribution
|
||||
footerText: '', // custom footer / copyright text
|
||||
showPoweredBy: 'true', // 'true' | 'false' — show "Powered by BetterDesk"
|
||||
|
||||
// Agent download portal (global defaults shared by all bundles)
|
||||
agentBgType: 'none', // 'none' | 'color' | 'gradient' | 'image'
|
||||
agentBgColor: '',
|
||||
agentBgGradient: '',
|
||||
agentBgImageUrl: '',
|
||||
agentShowPoweredBy: 'true', // 'true' | 'false'
|
||||
|
||||
// Advanced — custom CSS escape hatch (sanitized)
|
||||
customCss: '',
|
||||
|
||||
// Color scheme overrides (empty = use defaults from variables.css)
|
||||
colors: {
|
||||
bgPrimary: '',
|
||||
@@ -222,11 +325,21 @@ async function saveBranding(updates) {
|
||||
} else if (key in DEFAULT_BRANDING) {
|
||||
// Security: Sanitize SVG content to prevent XSS
|
||||
if (key === 'logoSvg' || key === 'faviconSvg') {
|
||||
entries.push({ key, value: sanitizeSvg(String(value)) }); } else if (key === 'logoUrl' || key === 'faviconUrl') {
|
||||
entries.push({ key, value: sanitizeSvg(String(value)) }); } else if (key === 'logoUrl' || key === 'faviconUrl' ||
|
||||
key === 'bgImageUrl' || key === 'loginBgImageUrl' || key === 'agentBgImageUrl') {
|
||||
// Security: Validate URL scheme to prevent javascript:/data:/file:/protocol-relative XSS/SSRF.
|
||||
const normalized = validateBrandingUrl(value);
|
||||
if (normalized === null) continue; // skip invalid value, keep previous DB value
|
||||
entries.push({ key, value: normalized }); } else {
|
||||
entries.push({ key, value: normalized });
|
||||
} else if (key === 'bgColor' || key === 'bgGradient' ||
|
||||
key === 'loginBgColor' || key === 'loginBgGradient' ||
|
||||
key === 'agentBgColor' || key === 'agentBgGradient') {
|
||||
// Security: Restrict to a safe CSS color/gradient charset.
|
||||
entries.push({ key, value: sanitizeCssColorValue(value) });
|
||||
} else if (key === 'customCss') {
|
||||
// Security: Neutralize CSS-based XSS / external resource loading.
|
||||
entries.push({ key, value: sanitizeCustomCss(value) });
|
||||
} else {
|
||||
entries.push({ key, value: String(value) });
|
||||
}
|
||||
}
|
||||
@@ -286,10 +399,91 @@ function generateThemeCss() {
|
||||
if (overrides.length > 0) {
|
||||
css += `:root {\n${overrides.join('\n')}\n}\n`;
|
||||
}
|
||||
|
||||
|
||||
// Background wallpaper (console + login) and custom CSS
|
||||
css += generateBackgroundCss(branding);
|
||||
|
||||
const customCss = sanitizeCustomCss(branding.customCss);
|
||||
if (customCss.trim()) {
|
||||
css += `\n/* --- custom branding CSS --- */\n${customCss}\n`;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
/** Clamp a numeric branding input (blur px / overlay %) to a safe range. */
|
||||
function clampNumber(value, min, max) {
|
||||
const n = parseFloat(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the wallpaper / overlay CSS for the console (.app-page) and the
|
||||
* login page (.login-page). The wallpaper sits on a fixed pseudo-element behind
|
||||
* all content; the scrollable content area is made transparent so cards float
|
||||
* over it. Login background can either inherit the console one or override it.
|
||||
* @param {Object} branding
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateBackgroundCss(branding) {
|
||||
let out = '';
|
||||
|
||||
const sizeRule = (size) => {
|
||||
switch (size) {
|
||||
case 'contain': return 'background-size: contain; background-repeat: no-repeat; background-position: center;';
|
||||
case 'repeat': return 'background-repeat: repeat;';
|
||||
case 'center': return 'background-size: auto; background-repeat: no-repeat; background-position: center;';
|
||||
case 'cover':
|
||||
default: return 'background-size: cover; background-repeat: no-repeat; background-position: center;';
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Console wallpaper ----
|
||||
const consoleBg = buildBackgroundValue(branding.bgType, branding.bgColor, branding.bgGradient, branding.bgImageUrl);
|
||||
if (consoleBg) {
|
||||
const blur = clampNumber(branding.bgBlur, 0, 40);
|
||||
const overlay = clampNumber(branding.bgOverlay, 0, 95);
|
||||
out += `body.app-page::before {\n` +
|
||||
` content: '';\n position: fixed;\n inset: 0;\n z-index: -2;\n` +
|
||||
` background: ${consoleBg};\n ${branding.bgType === 'image' ? sizeRule(branding.bgSize) : ''}\n` +
|
||||
(blur ? ` filter: blur(${blur}px);\n transform: scale(1.05);\n` : '') +
|
||||
`}\n`;
|
||||
if (overlay) {
|
||||
out += `body.app-page::after {\n` +
|
||||
` content: '';\n position: fixed;\n inset: 0;\n z-index: -1;\n` +
|
||||
` background: rgba(0, 0, 0, ${(overlay / 100).toFixed(2)});\n pointer-events: none;\n}\n`;
|
||||
}
|
||||
// Let the wallpaper show behind floating cards in the content area.
|
||||
out += `body.app-page { background-color: transparent; }\n`;
|
||||
out += `body.app-page .main-content { background: transparent; }\n`;
|
||||
}
|
||||
|
||||
// ---- Login wallpaper ----
|
||||
let loginBg = '';
|
||||
let loginOverlay = null;
|
||||
if (branding.loginBgType === 'inherit') {
|
||||
loginBg = consoleBg;
|
||||
loginOverlay = clampNumber(branding.bgOverlay, 0, 95);
|
||||
} else if (branding.loginBgType && branding.loginBgType !== 'none') {
|
||||
loginBg = buildBackgroundValue(branding.loginBgType, branding.loginBgColor, branding.loginBgGradient, branding.loginBgImageUrl);
|
||||
loginOverlay = clampNumber(branding.loginBgOverlay, 0, 95);
|
||||
}
|
||||
if (loginBg) {
|
||||
out += `body.login-page::before {\n` +
|
||||
` content: '';\n position: fixed;\n inset: 0;\n z-index: -2;\n` +
|
||||
` background: ${loginBg};\n background-size: cover;\n background-position: center;\n background-repeat: no-repeat;\n}\n`;
|
||||
if (loginOverlay) {
|
||||
out += `body.login-page::after {\n` +
|
||||
` content: '';\n position: fixed;\n inset: 0;\n z-index: -1;\n` +
|
||||
` background: rgba(0, 0, 0, ${(loginOverlay / 100).toFixed(2)});\n pointer-events: none;\n}\n`;
|
||||
}
|
||||
out += `body.login-page { background-color: transparent; }\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate favicon SVG from branding
|
||||
* @returns {string} SVG markup for favicon
|
||||
@@ -353,8 +547,12 @@ async function importPreset(preset) {
|
||||
}
|
||||
sanitized.colors = colors;
|
||||
} else {
|
||||
// Limit string length for safety
|
||||
sanitized[key] = String(preset.branding[key]).substring(0, key === 'logoSvg' || key === 'faviconSvg' ? 50000 : 500);
|
||||
// Limit string length for safety (larger caps for markup/CSS fields)
|
||||
let cap = 500;
|
||||
if (key === 'logoSvg' || key === 'faviconSvg') cap = 50000;
|
||||
else if (key === 'customCss') cap = 20000;
|
||||
else if (key === 'bgGradient' || key === 'loginBgGradient' || key === 'agentBgGradient') cap = 1000;
|
||||
sanitized[key] = String(preset.branding[key]).substring(0, cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,5 +581,8 @@ module.exports = {
|
||||
importPreset,
|
||||
invalidateCache,
|
||||
sanitizeSvg,
|
||||
sanitizeCssColorValue,
|
||||
sanitizeCustomCss,
|
||||
buildBackgroundValue,
|
||||
validateBrandingUrl
|
||||
};
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
// Public download portal — branded per bundle. No auth, no shared layout.
|
||||
// Bundle ID itself is the access token.
|
||||
const brand = bundle.branding || {};
|
||||
const gb = typeof globalBranding !== 'undefined' && globalBranding ? globalBranding : {};
|
||||
const primary = brand.primary_color || '#2563eb';
|
||||
const accent = brand.accent_color || '#1e293b';
|
||||
const companyName = brand.company_name || 'BetterDesk Support';
|
||||
const companyName = brand.company_name || gb.appName || 'BetterDesk Support';
|
||||
const shortText = brand.short_text || '';
|
||||
const logo = brand.logo_data_url || '';
|
||||
const logo = brand.logo_data_url || gb.logoUrl || '';
|
||||
const contactEmail = brand.contact_email || '';
|
||||
const contactPhone = brand.contact_phone || '';
|
||||
const contactUrl = brand.contact_url || '';
|
||||
const portalBackground = gb.background || '';
|
||||
const showPoweredBy = gb.showPoweredBy !== false;
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
%><!DOCTYPE html>
|
||||
<html lang="<%= brand.default_lang || 'en' %>">
|
||||
@@ -25,6 +28,15 @@ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'
|
||||
--brand-primary: <%= esc(primary) %>;
|
||||
--brand-accent: <%= esc(accent) %>;
|
||||
}
|
||||
<% if (portalBackground) { %>
|
||||
body {
|
||||
background: <%- portalBackground %>;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
<% } %>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -100,7 +112,9 @@ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'
|
||||
<% } %>
|
||||
|
||||
<footer class="portal-footer">
|
||||
<% if (showPoweredBy) { %>
|
||||
<p><%= t('download.powered_by') %> <strong>BetterDesk</strong></p>
|
||||
<% } %>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<span class="login-logo-text"><%= appName %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<h1 class="login-title"><%= _('auth.login_title') %></h1>
|
||||
<p class="login-subtitle"><%= _('auth.login_subtitle') %></p>
|
||||
<h1 class="login-title"><%= branding.loginTitle || _('auth.login_title') %></h1>
|
||||
<p class="login-subtitle"><%= branding.loginSubtitle || _('auth.login_subtitle') %></p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
@@ -171,7 +171,12 @@
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<% if (branding.footerText) { %>
|
||||
<p class="login-footer-custom"><%= branding.footerText %></p>
|
||||
<% } %>
|
||||
<% if (branding.showPoweredBy !== 'false') { %>
|
||||
<p><%= appName %> v<%= appVersion %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,6 +295,12 @@
|
||||
<div class="sidebar-user-role"><%= _roleLabel %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% if ((branding && branding.footerText) || (branding && branding.showPoweredBy !== 'false')) { %>
|
||||
<div class="sidebar-attribution">
|
||||
<% if (branding.footerText) { %><span class="sidebar-attribution-text"><%= branding.footerText %></span><% } %>
|
||||
<% if (branding.showPoweredBy !== 'false') { %><span class="sidebar-attribution-powered"><%= _('branding.powered_by') %> BetterDesk</span><% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -543,6 +543,346 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background & Appearance -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<h2 class="settings-section-title">
|
||||
<span class="material-icons">wallpaper</span>
|
||||
${_('branding.background_title')}
|
||||
</h2>
|
||||
<p class="settings-section-desc">${_('branding.background_desc')}</p>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<div class="branding-bg-config">
|
||||
<div class="logo-type-selector">
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="bg-type" value="none">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">block</span>
|
||||
<span>${_('branding.bg_type_none')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="bg-type" value="color">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">format_color_fill</span>
|
||||
<span>${_('branding.bg_type_color')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="bg-type" value="gradient">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">gradient</span>
|
||||
<span>${_('branding.bg_type_gradient')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="bg-type" value="image">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">image</span>
|
||||
<span>${_('branding.bg_type_image')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="bg-color-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bg-color">${_('branding.bg_color')}</label>
|
||||
<div class="color-input-wrapper">
|
||||
<input type="color" class="color-picker" id="bg-color-picker" value="#0d1117">
|
||||
<input type="text" class="form-input color-hex" id="bg-color"
|
||||
placeholder="#0d1117" maxlength="32">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="bg-gradient-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bg-gradient">${_('branding.bg_gradient')}</label>
|
||||
<input type="text" id="bg-gradient" class="form-input"
|
||||
placeholder="linear-gradient(135deg, #1e293b 0%, #0f172a 100%)" maxlength="400">
|
||||
<small class="form-hint">${_('branding.bg_gradient_hint')}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="bg-image-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label">${_('branding.bg_image_upload')}</label>
|
||||
<div class="logo-upload-area">
|
||||
<input type="file" id="bg-image-file" data-target="bg-image-url" accept="image/png,image/jpeg,image/webp,image/gif" class="logo-file-input branding-bg-file">
|
||||
<label for="bg-image-file" class="logo-upload-btn">
|
||||
<span class="material-icons">upload_file</span>
|
||||
<span>${_('branding.bg_image_choose')}</span>
|
||||
</label>
|
||||
<span id="bg-file-name" class="logo-file-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bg-image-url">${_('branding.bg_image_url')}</label>
|
||||
<input type="text" id="bg-image-url" class="form-input"
|
||||
placeholder="https://example.com/wallpaper.jpg">
|
||||
<small class="form-hint">${_('branding.bg_image_hint')}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bg-size">${_('branding.bg_size')}</label>
|
||||
<select id="bg-size" class="form-input form-select">
|
||||
<option value="cover">${_('branding.bg_size_cover')}</option>
|
||||
<option value="contain">${_('branding.bg_size_contain')}</option>
|
||||
<option value="auto">${_('branding.bg_size_auto')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="bg-effects-group">
|
||||
<label class="form-label" for="bg-blur">${_('branding.bg_blur')} (<span id="bg-blur-value">0</span>px)</label>
|
||||
<input type="range" id="bg-blur" class="form-range" min="0" max="30" step="1" value="0">
|
||||
</div>
|
||||
<div class="form-group" id="bg-overlay-group">
|
||||
<label class="form-label" for="bg-overlay">${_('branding.bg_overlay')} (<span id="bg-overlay-value">0</span>%)</label>
|
||||
<input type="range" id="bg-overlay" class="form-range" min="0" max="90" step="5" value="0">
|
||||
<small class="form-hint">${_('branding.bg_overlay_hint')}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Page -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<h2 class="settings-section-title">
|
||||
<span class="material-icons">login</span>
|
||||
${_('branding.login_title')}
|
||||
</h2>
|
||||
<p class="settings-section-desc">${_('branding.login_desc')}</p>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<form class="settings-form branding-form" style="max-width: 600px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-title">${_('branding.login_heading')}</label>
|
||||
<input type="text" id="login-title" class="form-input" maxlength="80"
|
||||
placeholder="${_('branding.login_heading_placeholder')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-subtitle">${_('branding.login_subheading')}</label>
|
||||
<input type="text" id="login-subtitle" class="form-input" maxlength="160"
|
||||
placeholder="${_('branding.login_subheading_placeholder')}">
|
||||
</div>
|
||||
</form>
|
||||
<div class="branding-bg-config" style="margin-top: 16px;">
|
||||
<div class="logo-type-selector">
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="login-bg-type" value="inherit">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">sync</span>
|
||||
<span>${_('branding.login_bg_inherit')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="login-bg-type" value="color">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">format_color_fill</span>
|
||||
<span>${_('branding.bg_type_color')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="login-bg-type" value="gradient">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">gradient</span>
|
||||
<span>${_('branding.bg_type_gradient')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="login-bg-type" value="image">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">image</span>
|
||||
<span>${_('branding.bg_type_image')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="login-bg-color-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-bg-color">${_('branding.bg_color')}</label>
|
||||
<div class="color-input-wrapper">
|
||||
<input type="color" class="color-picker" id="login-bg-color-picker" value="#0d1117">
|
||||
<input type="text" class="form-input color-hex" id="login-bg-color"
|
||||
placeholder="#0d1117" maxlength="32">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="login-bg-gradient-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-bg-gradient">${_('branding.bg_gradient')}</label>
|
||||
<input type="text" id="login-bg-gradient" class="form-input"
|
||||
placeholder="linear-gradient(135deg, #1e293b 0%, #0f172a 100%)" maxlength="400">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="login-bg-image-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label">${_('branding.bg_image_upload')}</label>
|
||||
<div class="logo-upload-area">
|
||||
<input type="file" id="login-bg-image-file" data-target="login-bg-image-url" accept="image/png,image/jpeg,image/webp,image/gif" class="logo-file-input branding-bg-file">
|
||||
<label for="login-bg-image-file" class="logo-upload-btn">
|
||||
<span class="material-icons">upload_file</span>
|
||||
<span>${_('branding.bg_image_choose')}</span>
|
||||
</label>
|
||||
<span id="login-bg-file-name" class="logo-file-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-bg-image-url">${_('branding.bg_image_url')}</label>
|
||||
<input type="text" id="login-bg-image-url" class="form-input"
|
||||
placeholder="https://example.com/login-bg.jpg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-bg-overlay">${_('branding.bg_overlay')} (<span id="login-bg-overlay-value">0</span>%)</label>
|
||||
<input type="range" id="login-bg-overlay" class="form-range" min="0" max="90" step="5" value="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Download Page -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<h2 class="settings-section-title">
|
||||
<span class="material-icons">download</span>
|
||||
${_('branding.agent_title')}
|
||||
</h2>
|
||||
<p class="settings-section-desc">${_('branding.agent_desc')}</p>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<div class="branding-bg-config">
|
||||
<div class="logo-type-selector">
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="agent-bg-type" value="none">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">block</span>
|
||||
<span>${_('branding.bg_type_none')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="agent-bg-type" value="color">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">format_color_fill</span>
|
||||
<span>${_('branding.bg_type_color')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="agent-bg-type" value="gradient">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">gradient</span>
|
||||
<span>${_('branding.bg_type_gradient')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="logo-type-option">
|
||||
<input type="radio" name="agent-bg-type" value="image">
|
||||
<span class="logo-type-card">
|
||||
<span class="material-icons">image</span>
|
||||
<span>${_('branding.bg_type_image')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="agent-bg-color-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-bg-color">${_('branding.bg_color')}</label>
|
||||
<div class="color-input-wrapper">
|
||||
<input type="color" class="color-picker" id="agent-bg-color-picker" value="#f1f5f9">
|
||||
<input type="text" class="form-input color-hex" id="agent-bg-color"
|
||||
placeholder="#f1f5f9" maxlength="32">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="agent-bg-gradient-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-bg-gradient">${_('branding.bg_gradient')}</label>
|
||||
<input type="text" id="agent-bg-gradient" class="form-input"
|
||||
placeholder="linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%)" maxlength="400">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-config-panel hidden" id="agent-bg-image-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label">${_('branding.bg_image_upload')}</label>
|
||||
<div class="logo-upload-area">
|
||||
<input type="file" id="agent-bg-image-file" data-target="agent-bg-image-url" accept="image/png,image/jpeg,image/webp,image/gif" class="logo-file-input branding-bg-file">
|
||||
<label for="agent-bg-image-file" class="logo-upload-btn">
|
||||
<span class="material-icons">upload_file</span>
|
||||
<span>${_('branding.bg_image_choose')}</span>
|
||||
</label>
|
||||
<span id="agent-bg-file-name" class="logo-file-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-bg-image-url">${_('branding.bg_image_url')}</label>
|
||||
<input type="text" id="agent-bg-image-url" class="form-input"
|
||||
placeholder="https://example.com/agent-bg.jpg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group toggle-group" style="display:flex;align-items:center;gap:12px;margin-top:8px;">
|
||||
<input type="checkbox" id="agent-show-powered" class="toggle-switch" checked>
|
||||
<div>
|
||||
<label class="form-label" for="agent-show-powered" style="margin-bottom:0;cursor:pointer;">${_('branding.agent_show_powered')}</label>
|
||||
<small class="form-hint" style="display:block;margin-top:2px;">${_('branding.agent_show_powered_hint')}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer & Copyright -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<h2 class="settings-section-title">
|
||||
<span class="material-icons">copyright</span>
|
||||
${_('branding.footer_title')}
|
||||
</h2>
|
||||
<p class="settings-section-desc">${_('branding.footer_desc')}</p>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<form class="settings-form branding-form" style="max-width: 600px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="footer-text">${_('branding.footer_text')}</label>
|
||||
<input type="text" id="footer-text" class="form-input" maxlength="200"
|
||||
placeholder="© 2026 Your Company. All rights reserved.">
|
||||
<small class="form-hint">${_('branding.footer_text_hint')}</small>
|
||||
</div>
|
||||
<div class="form-group toggle-group" style="display:flex;align-items:center;gap:12px;">
|
||||
<input type="checkbox" id="show-powered" class="toggle-switch" checked>
|
||||
<div>
|
||||
<label class="form-label" for="show-powered" style="margin-bottom:0;cursor:pointer;">${_('branding.show_powered')}</label>
|
||||
<small class="form-hint" style="display:block;margin-top:2px;">${_('branding.show_powered_hint')}</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<h2 class="settings-section-title">
|
||||
<span class="material-icons">code</span>
|
||||
${_('branding.custom_css_title')}
|
||||
</h2>
|
||||
<p class="settings-section-desc">${_('branding.custom_css_desc')}</p>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<div class="form-group" style="max-width: 800px;">
|
||||
<textarea id="custom-css" class="form-input form-textarea code-textarea" rows="10"
|
||||
placeholder=".sidebar { box-shadow: 0 0 20px rgba(0,0,0,.4); }"></textarea>
|
||||
<small class="form-hint">${_('branding.custom_css_hint')}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import / Export -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
|
||||
Reference in New Issue
Block a user