// adminPanel.js import { t } from './i18n.js?v={{APP_QVER}}'; import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}'; import { sendRequest } from './networkUtils.js?v={{APP_QVER}}'; function normalizeLogoPath(raw) { if (!raw) return ''; const parts = String(raw).split(':'); let pic = parts[parts.length - 1]; pic = pic.replace(/^:+/, ''); if (pic && !pic.startsWith('/')) pic = '/' + pic; return pic; } const version = window.APP_VERSION || "dev"; // Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only. // Update this when I cut a new Pro ZIP. const PRO_LATEST_BUNDLE_VERSION = 'v1.0.1'; function getAdminTitle(isPro, proVersion) { const corePill = ` Core ${version} `; // Normalize versions so "v1.0.1" and "1.0.1" compare cleanly const norm = (v) => String(v || '').trim().replace(/^v/i, ''); const latestRaw = (typeof PRO_LATEST_BUNDLE_VERSION !== 'undefined' ? PRO_LATEST_BUNDLE_VERSION : '' ); const currentRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : ''; const hasCurrent = !!norm(currentRaw); const hasLatest = !!norm(latestRaw); const hasUpdate = isPro && hasCurrent && hasLatest && norm(currentRaw) !== norm(latestRaw); if (!isPro) { // Free/core only return ` ${t("admin_panel")} ${corePill} `; } const pvLabel = hasCurrent ? `Pro v${norm(currentRaw)}` : 'Pro'; const proPill = ` ${pvLabel} `; const updateHint = hasUpdate ? ` Pro update available ` : ''; return ` ${t("admin_panel")} ${corePill} ${proPill} ${updateHint} `; } function buildFullGrantsForAllFolders(folders) { const allTrue = { view: true, viewOwn: false, manage: true, create: true, upload: true, edit: true, rename: true, copy: true, move: true, delete: true, extract: true, shareFile: true, shareFolder: true, share: true }; return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {}); } function applyHeaderColorsFromAdmin() { try { const lightInput = document.getElementById('brandingHeaderBgLight'); const darkInput = document.getElementById('brandingHeaderBgDark'); const root = document.documentElement; const light = lightInput ? lightInput.value.trim() : ''; const dark = darkInput ? darkInput.value.trim() : ''; if (light) root.style.setProperty('--header-bg-light', light); else root.style.removeProperty('--header-bg-light'); if (dark) root.style.setProperty('--header-bg-dark', dark); else root.style.removeProperty('--header-bg-dark'); } catch (e) { console.warn('Failed to live-update header colors from admin panel', e); } } function updateHeaderLogoFromAdmin() { try { const input = document.getElementById('brandingCustomLogoUrl'); const logoImg = document.querySelector('.header-logo img'); if (!logoImg) return; let url = (input && input.value.trim()) || ''; // If they used a bare "uploads/..." path, normalize to "/uploads/..." if (url && !url.startsWith('/') && url.startsWith('uploads/')) { url = '/' + url; } // ---- Sanitize URL (mirror AdminModel::sanitizeLogoUrl) ---- const isHttp = /^https?:\/\//i.test(url); const isSiteRelative = url.startsWith('/') && !url.includes('://'); // Strip any CR/LF just in case url = url.replace(/[\r\n]+/g, ''); if (url && (isHttp || isSiteRelative)) { // safe enough for logoImg.setAttribute('src', url); logoImg.setAttribute('alt', 'Site logo'); } else { // fall back to default FileRise logo logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}'); logoImg.setAttribute('alt', 'FileRise'); } } catch (e) { console.warn('Failed to live-update header logo from admin panel', e); } } /* === BEGIN: Folder Access helpers (merged + improved) === */ function qs(scope, sel) { return (scope || document).querySelector(sel); } function qsa(scope, sel) { return Array.from((scope || document).querySelectorAll(sel)); } function enforceShareFolderRule(row) { const manage = qs(row, 'input[data-cap="manage"]'); const viewAll = qs(row, 'input[data-cap="view"]'); const shareFolder = qs(row, 'input[data-cap="shareFolder"]'); if (!shareFolder) return; const ok = !!(manage && manage.checked) && !!(viewAll && viewAll.checked); if (!ok) { shareFolder.checked = false; shareFolder.disabled = true; shareFolder.setAttribute('data-disabled-reason', 'Requires Manage + View (all)'); } else { shareFolder.disabled = false; shareFolder.removeAttribute('data-disabled-reason'); } } function wireHeaderTitleLive() { const input = document.getElementById('headerTitle'); if (!input || input.__live) return; input.__live = true; const apply = (val) => { const title = (val || '').trim() || 'FileRise'; const h1 = document.querySelector('.header-title h1'); if (h1) h1.textContent = title; document.title = title; window.headerTitle = val || ''; // preserve raw value user typed try { localStorage.setItem('headerTitle', title); } catch { } }; // apply current value immediately + on each keystroke apply(input.value); input.addEventListener('input', (e) => apply(e.target.value)); } function renderMaskedInput({ id, label, hasValue, isSecret = false }) { const type = isSecret ? 'password' : 'text'; const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : 'data-replace="1"'; const replaceBtn = hasValue ? `` : ''; const note = hasValue ? `Saved — leave blank to keep` : ''; return `
${replaceBtn}
${note}
`; } function wireReplaceButtons(scope = document) { scope.querySelectorAll('[data-replace-for]').forEach(btn => { if (btn.__wired) return; btn.__wired = true; btn.addEventListener('click', () => { const id = btn.getAttribute('data-replace-for'); const inp = scope.querySelector('#' + id); if (!inp) return; inp.disabled = false; inp.dataset.replace = '1'; inp.placeholder = ''; inp.value = ''; btn.textContent = 'Keep saved value'; btn.removeAttribute('data-replace-for'); btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true }); }, { once: true }); }); } function onShareFolderToggle(row, checked) { const manage = qs(row, 'input[data-cap="manage"]'); const viewAll = qs(row, 'input[data-cap="view"]'); if (checked) { if (manage && !manage.checked) manage.checked = true; if (viewAll && !viewAll.checked) viewAll.checked = true; } enforceShareFolderRule(row); } function onShareFileToggle(row, checked) { if (!checked) return; const viewAll = qs(row, 'input[data-cap="view"]'); const viewOwn = qs(row, 'input[data-cap="viewOwn"]'); const hasView = !!(viewAll && viewAll.checked); const hasOwn = !!(viewOwn && viewOwn.checked); if (!hasView && !hasOwn && viewOwn) { viewOwn.checked = true; } } function onWriteToggle(row, checked) { const caps = ["create", "upload", "edit", "rename", "copy", "delete", "extract"]; caps.forEach(c => { const box = qs(row, `input[data-cap="${c}"]`); if (box) box.checked = checked; }); } /* === END: Folder Access helpers (merged + improved) === */ // Translate with fallback const tf = (key, fallback) => { const v = t(key); return (v && v !== key) ? v : fallback; }; // --- tiny robust JSON helper --- async function safeJson(res) { const text = await res.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ } if (!res.ok) { const msg = (body && (body.error || body.message)) || (text && text.trim()) || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; throw err; } return body ?? {}; } // ————— Inject updated styles ————— (function () { if (document.getElementById('adminPanelStyles')) return; const style = document.createElement('style'); style.id = 'adminPanelStyles'; style.textContent = ` /* Modal sizing */ #adminPanelModal .modal-content { max-width: 1100px; width: 50%; background: #fff !important; color: #000 !important; border: 1px solid #ccc !important; } @media (max-width: 900px) { #adminPanelModal .modal-content { width: 90% !important; max-width: none !important; } } .dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; } .dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; } .dark-mode .form-control::placeholder { color:#888; } .section-header { background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold; display:flex; align-items:center; justify-content:space-between; margin-top:16px; } .section-header:first-of-type { margin-top:0; } .section-header.collapsed .material-icons { transform:rotate(-90deg); } .section-header .material-icons { transition:transform .3s; color:#444; } .dark-mode .section-header { background:#3a3a3a; color:#eee; } .dark-mode .section-header .material-icons { color:#ccc; } .section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; } #adminPanelModal .editor-close-btn { position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9); border:2px solid transparent; transition:all .3s; } #adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); } .dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; } .action-row { display:flex; justify-content:space-between; margin-top:15px; } /* ---------- Folder access editor ---------- */ .folder-access-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px; } .folder-access-list { --col-perm: 84px; --col-folder-min: 340px; max-height: 320px; overflow: auto; border: 1px solid #ccc; border-radius: 6px; padding: 0; } .dark-mode .folder-access-list { border-color:#555; } .folder-access-header, .folder-access-row { display: grid; grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(14, var(--col-perm)); gap: 8px; align-items: center; padding: 8px 10px; } .folder-access-header { position: sticky; top: 0; z-index: 2; background: #fff; font-weight: 700; border-bottom: 1px solid rgba(0,0,0,0.12); } .dark-mode .folder-access-header { background:#2c2c2c; } .folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); } .folder-access-row:last-child { border-bottom: none; } .perm-col { text-align:center; white-space:nowrap; } .folder-access-header > div { white-space: nowrap; } .folder-badge { display:inline-flex; align-items:center; gap:6px; font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; min-width: 0; } .muted { opacity:.65; font-size:.9em; } /* Inheritance visuals */ .inherited-row { opacity: 0.8; background: rgba(32, 132, 255, 0.06); } .inherited-tag { font-size: 11px; padding: 2px 6px; border-radius: 10px; background: rgba(32,132,255,0.12); color: #2064ff; margin-left: 6px; } .dark-mode .inherited-row { background: rgba(32,132,255,0.12); } .dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; } @media (max-width: 900px) { .folder-access-list { --col-perm: 72px; --col-folder-min: 240px; } } /* Folder cell: horizontal-only scroll */ .folder-cell{ overflow-x:auto; overflow-y:hidden; white-space:nowrap; -webkit-overflow-scrolling:touch; } /* nicer thin scrollbar (supported browsers) */ .folder-cell::-webkit-scrollbar{ height:8px; } .folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; } .dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); } /* Badge now doesn't clip; let the wrapper handle scroll */ .folder-badge{ display:inline-flex; align-items:center; gap:6px; font-weight:600; min-width:0; /* allow child to be as wide as needed inside scroller */ } .group-members-chips { display: flex; flex-wrap: wrap; gap: 4px; } .group-member-pill { display: inline-flex; align-items: center; padding: 2px 6px; border-radius: 999px; font-size: 11px; background-color: #1e88e5; color: #fff; } .dark-mode .group-member-pill { background-color: #1565c0; color: #fff; } `; document.head.appendChild(style); })(); // ———————————————————————————————————— let originalAdminConfig = {}; function captureInitialAdminConfig() { const ht = document.getElementById("headerTitle"); originalAdminConfig = { headerTitle: ht ? ht.value.trim() : "", oidcProviderUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(), oidcClientId: (document.getElementById("oidcClientId")?.value || "").trim(), oidcClientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(), oidcRedirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim(), disableFormLogin: !!document.getElementById("disableFormLogin")?.checked, disableBasicAuth: !!document.getElementById("disableBasicAuth")?.checked, disableOIDCLogin: !!document.getElementById("disableOIDCLogin")?.checked, enableWebDAV: !!document.getElementById("enableWebDAV")?.checked, sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(), globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(), brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(), brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(), brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(), }; } function hasUnsavedChanges() { const o = originalAdminConfig; const getVal = id => (document.getElementById(id)?.value || "").trim(); const getChk = id => !!document.getElementById(id)?.checked; return ( getVal("headerTitle") !== o.headerTitle || getVal("oidcProviderUrl") !== o.oidcProviderUrl || getVal("oidcClientId") !== o.oidcClientId || getVal("oidcClientSecret") !== o.oidcClientSecret || getVal("oidcRedirectUri") !== o.oidcRedirectUri || getChk("disableFormLogin") !== o.disableFormLogin || getChk("disableBasicAuth") !== o.disableBasicAuth || getChk("disableOIDCLogin") !== o.disableOIDCLogin || getChk("enableWebDAV") !== o.enableWebDAV || getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize || getVal("globalOtpauthUrl") !== o.globalOtpauthUrl || getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") || getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") || getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ); } function showCustomConfirmModal(message) { return new Promise(resolve => { const modal = document.getElementById("customConfirmModal"); const msg = document.getElementById("confirmMessage"); const yes = document.getElementById("confirmYesBtn"); const no = document.getElementById("confirmNoBtn"); if (!modal || !msg || !yes || !no) { resolve(true); return; } msg.textContent = message; modal.style.display = "block"; function clean() { modal.style.display = "none"; yes.removeEventListener("click", onYes); no.removeEventListener("click", onNo); } function onYes() { clean(); resolve(true); } function onNo() { clean(); resolve(false); } yes.addEventListener("click", onYes); no.addEventListener("click", onNo); }); } function toggleSection(id) { const hdr = document.getElementById(id + "Header"); const cnt = document.getElementById(id + "Content"); if (!hdr || !cnt) return; const isCollapsedNow = hdr.classList.toggle("collapsed"); cnt.style.display = isCollapsedNow ? "none" : "block"; if (!isCollapsedNow && id === "shareLinks") { loadShareLinksSection(); } } export function initProBundleInstaller() { try { const fileInput = document.getElementById('proBundleFile'); const btn = document.getElementById('btnInstallProBundle'); const statusEl = document.getElementById('proBundleStatus'); if (!fileInput || !btn || !statusEl) return; // Allow names like: FileRisePro_v1.0.0.zip or FileRisePro-1.0.0.zip const PRO_ZIP_NAME_RE = /^FileRisePro[_-]v?[0-9]+\.[0-9]+\.[0-9]+\.zip$/i; btn.addEventListener('click', async () => { const file = fileInput.files && fileInput.files[0]; if (!file) { statusEl.textContent = 'Choose a FileRise Pro .zip bundle first.'; statusEl.className = 'small text-danger'; return; } const name = file.name || ''; if (!PRO_ZIP_NAME_RE.test(name)) { statusEl.textContent = 'Bundle must be named like "FileRisePro_v1.0.0.zip".'; statusEl.className = 'small text-danger'; return; } const formData = new FormData(); formData.append('bundle', file); statusEl.textContent = 'Uploading and installing Pro bundle...'; statusEl.className = 'small text-muted'; try { const resp = await fetch('/api/admin/installProBundle.php', { method: 'POST', headers: { 'X-CSRF-Token': window.csrfToken || '' }, body: formData }); let data = null; try { data = await resp.json(); } catch (_) { // ignore JSON parse errors; handled below } if (!resp.ok || !data || !data.success) { const msg = data && data.error ? data.error : `HTTP ${resp.status}`; statusEl.textContent = 'Install failed: ' + msg; statusEl.className = 'small text-danger'; return; } const versionText = data.proVersion ? ` (version ${data.proVersion})` : ''; statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.'; statusEl.className = 'small text-success'; if (typeof loadAdminConfigFunc === 'function') { loadAdminConfigFunc(); } } catch (e) { statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e)); statusEl.className = 'small text-danger'; } }); } catch (e) { console.warn('Failed to init Pro bundle installer', e); } } function loadShareLinksSection() { const container = document.getElementById("shareLinksContent"); if (!container) return; container.textContent = t("loading") + "..."; function fetchMeta(fileName) { return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, { credentials: "include" }) .then(resp => resp.ok ? resp.json() : {}) .catch(() => ({})); } Promise.all([ fetchMeta("share_folder_links.json"), fetchMeta("share_links.json") ]) .then(([folders, files]) => { const hasAny = Object.keys(folders).length || Object.keys(files).length; if (!hasAny) { container.innerHTML = `

${t("no_shared_links_available")}

`; return; } let html = `
${t("folder_shares")}
${t("file_shares")}
`; container.innerHTML = html; container.querySelectorAll(".delete-share").forEach(btn => { btn.addEventListener("click", evt => { evt.preventDefault(); const token = btn.dataset.key; const isFolder = btn.dataset.type === "folder"; const endpoint = isFolder ? "/api/folder/deleteShareFolderLink.php" : "/api/file/deleteShareLink.php"; fetch(endpoint, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token }) }) .then(res => res.ok ? res.json() : Promise.reject(res)) .then(json => { if (json.success) { showToast(t("share_deleted_successfully")); loadShareLinksSection(); } else { showToast(t("error_deleting_share") + ": " + (json.error || ""), "error"); } }) .catch(err => { console.error("Delete error:", err); showToast(t("error_deleting_share"), "error"); }); }); }); }) .catch(err => { console.error("loadShareLinksSection error:", err); container.textContent = t("error_loading_share_links"); }); } export function openAdminPanel() { fetch("/api/admin/getConfig.php", { credentials: "include" }) .then(r => r.json()) .then(config => { if (config.header_title) { const h = document.querySelector(".header-title h1"); if (h) h.textContent = config.header_title; window.headerTitle = config.header_title; } if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc); if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl; const dark = document.body.classList.contains("dark-mode"); const proInfo = config.pro || {}; const isPro = !!proInfo.active; const proType = proInfo.type || ''; const proEmail = proInfo.email || ''; const proVersion = proInfo.version || 'not installed'; const proLicense = proInfo.license || ''; const brandingCfg = config.branding || {}; const brandingCustomLogoUrl = brandingCfg.customLogoUrl || ""; const brandingHeaderBgLight = brandingCfg.headerBgLight || ""; const brandingHeaderBgDark = brandingCfg.headerBgDark || ""; const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const inner = ` background:${dark ? "#2c2c2c" : "#fff"}; color:${dark ? "#e0e0e0" : "#000"}; padding:20px; max-width:1100px; width:50%; position:relative; max-height:90vh; overflow:auto; border:1px solid ${dark ? "#555" : "#ccc"}; `; let mdl = document.getElementById("adminPanelModal"); if (!mdl) { mdl = document.createElement("div"); mdl.id = "adminPanelModal"; mdl.style.cssText = ` position:fixed; top:0; left:0; width:100vw; height:100vh; background:${bg}; display:flex; justify-content:center; align-items:center; z-index:3000; `; mdl.innerHTML = ` `; document.body.appendChild(mdl); document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); ["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "pro", "sponsor"] .forEach(id => { document.getElementById(id + "Header") .addEventListener("click", () => toggleSection(id)); }); document.getElementById("userManagementContent").innerHTML = `
${ isPro ? `
` : `
Pro
` } ${ isPro ? `
` : `
Pro · Coming soon
` }
Use the core tools to manage users and per-folder access. User groups are available in Pro and Client upload portals are coming soon. `; document.getElementById("adminOpenAddUser") .addEventListener("click", () => { toggleVisibility("addUserModal", true); document.getElementById("newUsername")?.focus(); }); document.getElementById("adminOpenRemoveUser") .addEventListener("click", () => { if (typeof window.loadUserList === "function") window.loadUserList(); toggleVisibility("removeUserModal", true); }); document.getElementById("adminOpenUserPermissions") .addEventListener("click", openUserPermissionsModal); // Pro-only stubs for future features const regBtn = document.getElementById("adminOpenUserRegistration"); const groupsBtn = document.getElementById("adminOpenUserGroups"); const clientBtn = document.getElementById("adminOpenClientPortal"); if (regBtn) { regBtn.addEventListener("click", () => { if (!isPro) { showToast("User registration is a FileRise Pro feature. Visit filerise.net to purchase a license."); window.open("https://filerise.net", "_blank", "noopener"); return; } // Placeholder for future Pro UI: showToast("User registration management is coming soon in FileRise Pro."); }); } if (groupsBtn) { groupsBtn.addEventListener("click", () => { if (!isPro) { showToast("User groups are a FileRise Pro feature. Visit filerise.net to purchase a license."); window.open("https://filerise.net", "_blank", "noopener"); return; } openUserGroupsModal(); }); } if (clientBtn) { clientBtn.addEventListener("click", () => { if (!isPro) { showToast("Client portal uploads are a FileRise Pro feature. Visit filerise.net to purchase a license."); window.open("https://filerise.net", "_blank", "noopener"); return; } // Placeholder for future Pro UI: showToast("Client portal uploads are coming soon in FileRise Pro."); }); } document.getElementById("headerSettingsContent").innerHTML = `
${isPro ? 'Upload a logo image or paste a local path.' : 'Requires FileRise Pro to enable custom header branding.'}
${isPro ? 'If left empty, FileRise uses its default blue and dark header colors.' : 'Requires FileRise Pro to enable custom color branding.'}
`; wireHeaderTitleLive(); // Upload logo -> reuse profile picture endpoint, then fill the logo path if (isPro) { const fileInput = document.getElementById('brandingLogoFile'); const uploadBtn = document.getElementById('brandingUploadBtn'); const urlInput = document.getElementById('brandingCustomLogoUrl'); if (fileInput && uploadBtn && urlInput) { uploadBtn.addEventListener('click', async () => { const f = fileInput.files && fileInput.files[0]; if (!f) { showToast('Please choose an image first.'); return; } const fd = new FormData(); fd.append('brand_logo', f); // <- must match PHP field try { const res = await fetch('/api/pro/uploadBrandLogo.php', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken }, body: fd }); const text = await res.text(); let js = {}; try { js = JSON.parse(text || '{}'); } catch (e) { js = {}; } if (!res.ok || !js.url) { showToast(js.error || 'Error uploading logo'); return; } const normalized = normalizeLogoPath(js.url); // your helper urlInput.value = normalized; showToast('Logo uploaded. Don\'t forget to Save settings.'); } catch (e) { console.error(e); showToast('Error uploading logo'); } }); } } document.getElementById("loginOptionsContent").innerHTML = `
`; document.getElementById("webdavContent").innerHTML = `
`; document.getElementById("uploadContent").innerHTML = `
${t("max_bytes_shared_uploads_note")}
`; // ONLYOFFICE Content const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret); window.__HAS_OO_SECRET = hasOOSecret; document.getElementById("onlyofficeContent").innerHTML = `
Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”.
${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })} `; wireReplaceButtons(document.getElementById("onlyofficeContent")); // --- Test ONLYOFFICE block --- const testBox = document.createElement("div"); testBox.className = "card"; testBox.style.marginTop = "12px"; testBox.innerHTML = `
Test ONLYOFFICE connection
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
`; document.getElementById("onlyofficeContent").appendChild(testBox); // Util: tiny UI helpers for results function ooRow(label, status, detail = "") { const li = document.createElement("li"); li.style.margin = "6px 0"; const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌"; li.innerHTML = `${icon} ${label}${detail ? ` — ${detail}` : ""}`; return li; } function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); } // --- ONLYOFFICE URL sanitizers --- function getTrustedDocsOrigin(raw) { try { const u = new URL(String(raw || "").trim()); if (!/^https?:$/.test(u.protocol)) return null; // only http/https if (u.username || u.password) return null; // no creds in URL return u.origin; // scheme://host[:port] } catch { return null; } } function buildOnlyOfficeApiUrl(origin) { // fixed path; caller already validated/normalized origin const u = new URL('/web-apps/apps/api/documents/api.js', origin); u.searchParams.set('probe', String(Date.now())); return u.toString(); } // Probes that don’t explode your state async function ooProbeScript(docsOrigin) { return new Promise(resolve => { const base = getTrustedDocsOrigin(docsOrigin); if (!base) { resolve({ ok: false }); return; } const src = buildOnlyOfficeApiUrl(base); const s = document.createElement('script'); s.id = 'ooProbeScript'; s.async = true; s.src = src; // If you set a CSP nonce in a , attach it: const nonce = document.querySelector('meta[name="csp-nonce"]')?.content; if (nonce) s.setAttribute('nonce', nonce); const cleanup = () => { try { s.remove(); } catch {} }; s.onload = () => { cleanup(); resolve({ ok: true }); }; s.onerror = () => { cleanup(); resolve({ ok: false }); }; // codeql[js/xss-through-dom]: the origin is validated (http/https, no creds), // and the path is fixed to ONLYOFFICE api.js via URL(), so this is safe. document.head.appendChild(s); }); } async function ooProbeFrame(docsOrigin, timeoutMs = 4000) { return new Promise(resolve => { const base = getTrustedDocsOrigin(docsOrigin); if (!base) { resolve({ ok: false }); return; } const f = document.createElement('iframe'); f.id = 'ooProbeFrame'; f.src = base; // only the sanitized origin f.style.display = 'none'; // Optional: keep it extra constrained while probing. // If your DS needs broader privileges, you can drop sandbox. // f.sandbox = 'allow-same-origin allow-scripts'; const cleanup = () => { try { f.remove(); } catch {} }; const t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs); f.onload = () => { clearTimeout(t); cleanup(); resolve({ ok: true }); }; f.onerror = () => { clearTimeout(t); cleanup(); resolve({ ok: false }); }; // codeql[js/xss-through-dom]: src is constrained to a validated http/https origin. document.body.appendChild(f); }); } // Main test runner async function runOnlyOfficeTests() { const spinner = document.getElementById('ooTestSpinner'); const out = document.getElementById('ooTestResults'); const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim(); spinner.style.display = 'inline'; ooClear(out); // 1) FileRise status let statusOk = false, statusJson = null; try { const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' }); statusJson = await r.json().catch(() => ({})); if (r.ok) { if (statusJson.enabled) { out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready')); statusOk = true; } else { // Disabled usually means missing secret or origin; we’ll dig deeper below. out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin')); } } else { out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`)); } } catch (e) { out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error')); } // 2) Secret presence (fresh read) try { const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json()); const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret); out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing')); } catch { out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify')); } // 3) Callback reachable (basic ping) try { const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' }); if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable')); else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`)); } catch { out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error')); } // Early sanity on origin if (!/^https?:\/\//i.test(docsOrigin)) { out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)')); spinner.style.display = 'none'; return; } // 4a) Can browser load api.js (also surfaces CSP script-src issues) const sRes = await ooProbeScript(docsOrigin); out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)')); // 4b) Can browser embed DS in an iframe (CSP frame-src) const fRes = await ooProbeFrame(docsOrigin); out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)')); // Optional tip if we see common red flags if (!statusOk || !sRes.ok || !fRes.ok) { const tip = document.createElement('li'); tip.style.marginTop = '8px'; tip.innerHTML = "💡 Tip: Use the CSP helper above to include your Document Server in script-src, connect-src, and frame-src."; out.appendChild(tip); } spinner.style.display = 'none'; } // Wire the button document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests); // Append CSP help box // --- CSP help box (replace your whole block with this) --- const ooSec = document.getElementById("onlyofficeContent"); const cspHelp = document.createElement("div"); cspHelp.className = "alert alert-info"; cspHelp.style.marginTop = "12px"; cspHelp.innerHTML = `
Content-Security-Policy help
Add/replace this line in public/.htaccess (Apache). It allows loading ONLYOFFICE's api.js, embedding the editor iframe, and letting the script make XHR to your Document Server.

  
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead. Also note: if your site is https://, your ONLYOFFICE server must be https:// too, otherwise the browser will block it as mixed content.
Nginx equivalent

  
`; ooSec.appendChild(cspHelp); const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM="; function buildCspApache(originRaw) { const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, ''); const api = `${o}/web-apps/apps/api/documents/api.js`; return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`; } function buildCspNginx(originRaw) { const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, ''); const api = `${o}/web-apps/apps/api/documents/api.js`; return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`; } const ooDocsInput = document.getElementById("ooDocsOrigin"); const cspPre = document.getElementById("ooCspSnippet"); const cspPreNgx = document.getElementById("ooCspSnippetNginx"); function refreshCsp() { const raw = (ooDocsInput?.value || "").trim(); const base = getTrustedDocsOrigin(raw) || raw; // fall back to raw so users see their input cspPre.textContent = buildCspApache(base); cspPreNgx.textContent = buildCspNginx(base); } ooDocsInput?.addEventListener("input", refreshCsp); refreshCsp(); // ---- Copy helpers (with robust fallback) ---- async function copyToClipboard(text) { // Best path: async clipboard API in a secure context (https/localhost) if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch (_) { /* fall through */ } } // Fallback for http or blocked clipboard: hidden textarea + execCommand try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); // deprecated but still widely supported document.body.removeChild(ta); return ok; } catch (_) { return false; } } function selectElementContents(el) { const range = document.createRange(); range.selectNodeContents(el); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } document.getElementById("copyOoCsp")?.addEventListener("click", async () => { const txt = (cspPre.textContent || "").trim(); const ok = await copyToClipboard(txt); if (ok) { showToast("CSP line copied."); } else { // Auto-select so the user can Ctrl/Cmd+C as a last resort try { selectElementContents(cspPre); } catch { } const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)"; showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy."); } }); document.getElementById("selectOoCsp")?.addEventListener("click", () => { try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); } catch { /* ignore */ } }); document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled); document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : ""; const hasId = !!(config.oidc && config.oidc.hasClientId); const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); document.getElementById("oidcContent").innerHTML = `
Client ID/Secret are never shown after saving. A green note indicates a value is saved. Click “Replace” to overwrite.
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })} ${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
`; wireReplaceButtons(document.getElementById("oidcContent")); document.getElementById("shareLinksContent").textContent = t("loading") + "…"; document.getElementById("shareLinksContent").textContent = t("loading") + "…"; // --- FileRise Pro / License section --- const proContent = document.getElementById("proContent"); if (proContent) { // Normalize versions so "v1.0.1" and "1.0.1" compare cleanly const norm = (v) => (String(v || '').trim().replace(/^v/i, '')); const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : ''; const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || ''; const hasCurrent = !!norm(currentVersionRaw); const hasLatest = !!norm(latestVersionRaw); const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw); const proMetaHtml = isPro && (proType || proEmail || proVersion) ? `
✅ ${proType ? `License type: ${proType}` : 'License active'} ${proType && proEmail ? ' • ' : ''} ${proEmail ? `Licensed to: ${proEmail}` : ''}
${hasCurrent ? `
Installed Pro bundle: v${norm(currentVersionRaw)}
` : ''} ${hasLatest ? `
Latest Pro bundle (UI hint): ${latestVersionRaw}
` : ''}
` : ''; proContent.innerHTML = `
FileRise Pro ${isPro ? 'Active' : 'Free'}
${isPro ? 'Pro features are currently enabled on this instance.' : 'You are running the free edition. Enter a license key to activate FileRise Pro.'}
${proMetaHtml}
${isPro ? `
Download latest Pro bundle ${hasUpdate ? ` Update available ` : ''} Opens filerise.net in a new tab where you can enter your Pro license to download the latest FileRise Pro ZIP.
` : `
Buy FileRise Pro Opens filerise.net in a new tab so you can purchase a FileRise Pro license.
`}
${isPro && proLicense ? ` ` : ''}
You can purchase a license at filerise.net.
Supported: FileRise.lic, plain text with FRP1... or JSON containing a license field.
Install / update Pro bundle

Upload the .zip bundle you downloaded from filerise.net. This runs locally on your server and never contacts an external update service.

`; // Wire up local Pro bundle installer (upload .zip into core) initProBundleInstaller(); // Pre-fill textarea with saved license if present const licenseTextarea = document.getElementById('proLicenseInput'); if (licenseTextarea && proLicense) { licenseTextarea.value = proLicense; } // Auto-load license when a file is selected const fileInput = document.getElementById('proLicenseFile'); if (fileInput && licenseTextarea) { fileInput.addEventListener('change', () => { const file = fileInput.files && fileInput.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { let raw = String(e.target.result || '').trim(); let license = raw; try { const js = JSON.parse(raw); if (js && typeof js.license === 'string') { license = js.license.trim(); } } catch (_) { // not JSON, treat as plain text } if (!license || !license.startsWith('FRP1.')) { showToast('Could not find a valid FRP1 license in that file.'); return; } licenseTextarea.value = license; showToast('License loaded from file. Click "Save license" to apply.'); }; reader.onerror = () => { showToast('Error reading license file.'); }; reader.readAsText(file); }); } // Copy current license button (now inline next to the label) const proCopyBtn = document.getElementById('proCopyLicenseBtn'); if (proCopyBtn && proLicense) { proCopyBtn.addEventListener('click', async () => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(proLicense); } else { const ta = document.createElement('textarea'); ta.value = proLicense; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } showToast('License copied to clipboard.'); } catch { showToast('Could not copy license. Please copy it manually.'); } }); } // Save license handler (unchanged) const proSaveBtn = document.getElementById('proSaveLicenseBtn'); if (proSaveBtn) { proSaveBtn.addEventListener('click', async () => { const ta = document.getElementById('proLicenseInput'); const license = (ta && ta.value.trim()) || ''; try { const res = await fetch('/api/admin/setLicense.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '') }, body: JSON.stringify({ license }), }); const text = await res.text(); let data = {}; try { data = JSON.parse(text || '{}'); } catch (e) { data = {}; } if (!res.ok || !data.success) { console.error('setLicense error:', res.status, text); showToast(data.error || 'Error saving license'); return; } showToast('License saved. Reloading…'); window.location.reload(); } catch (e) { console.error(e); showToast('Error saving license'); } }); } } // --- end FileRise Pro section --- document.getElementById("saveAdminSettings") .addEventListener("click", handleSave); ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => { document.getElementById(id) .addEventListener("change", e => { const chk = ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"] .filter(i => document.getElementById(i).checked).length; if (chk === 3) { showToast(t("at_least_one_login_method")); e.target.checked = false; } }); }); document.getElementById("authBypass").addEventListener("change", e => { if (e.target.checked) { ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"] .forEach(i => document.getElementById(i).checked = false); } }); // --- Sponsor (fixed, non-editable) --- const SPONSOR_GH = "https://github.com/sponsors/error311"; const SPONSOR_KOFI = "https://ko-fi.com/error311"; document.getElementById("sponsorContent").innerHTML = `
Open
Open
${(typeof tf === 'function' ? tf("sponsor_note_fixed", "Please consider supporting ongoing development.") : "Please consider supporting ongoing development.")} `; // Wire copy + open (no changes tracked) const ghInput = document.getElementById("sponsorGitHub"); const kfInput = document.getElementById("sponsorKoFi"); document.getElementById("copySponsorGitHub").addEventListener("click", async () => { try { await navigator.clipboard.writeText(ghInput.value); } catch { } showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!"); }); document.getElementById("copySponsorKoFi").addEventListener("click", async () => { try { await navigator.clipboard.writeText(kfInput.value); } catch { } showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!"); }); document.getElementById("openSponsorGitHub").href = SPONSOR_GH; document.getElementById("openSponsorKoFi").href = SPONSOR_KOFI; const userMgmt = document.getElementById("userManagementContent"); userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick); window.__userMgmtDelegatedClick = (e) => { const flagsBtn = e.target.closest("#adminOpenUserFlags"); if (flagsBtn) { e.preventDefault(); openUserFlagsModal(); } const folderBtn = e.target.closest("#adminOpenUserPermissions"); if (folderBtn) { e.preventDefault(); openUserPermissionsModal(); } }; userMgmt?.addEventListener("click", window.__userMgmtDelegatedClick); document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("authBypass").checked = !!config.loginOptions.authBypass; document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User"; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; // remember lock for handleSave window.__OO_LOCKED = !!(config.onlyoffice && config.onlyoffice.lockedByPhp); if (window.__OO_LOCKED) { const sec = document.getElementById("onlyofficeContent"); sec.querySelectorAll("input,button").forEach(el => el.disabled = true); const note = document.createElement("div"); note.className = "form-text"; note.style.marginTop = "6px"; note.textContent = "Managed by config.php — edit ONLYOFFICE_* constants there."; sec.appendChild(note); } captureInitialAdminConfig(); } else { mdl.style.display = "flex"; const hasId = !!(config.oidc && config.oidc.hasClientId); const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("authBypass").checked = !!config.loginOptions.authBypass; document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User"; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || ""; const idEl = document.getElementById("oidcClientId"); const secEl = document.getElementById("oidcClientSecret"); if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || ""; if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || ""; wireReplaceButtons(document.getElementById("oidcContent")); document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled); document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : ""; const ooCont = document.getElementById("onlyofficeContent"); if (ooCont) wireReplaceButtons(ooCont); document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || ""; document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || ""; document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || ''; captureInitialAdminConfig(); } }) .catch(() => {/* if even fetching fails, open empty panel */ }); } function handleSave() { const payload = { header_title: document.getElementById("headerTitle")?.value || "", loginOptions: { disableFormLogin: document.getElementById("disableFormLogin").checked, disableBasicAuth: document.getElementById("disableBasicAuth").checked, disableOIDCLogin: document.getElementById("disableOIDCLogin").checked, authBypass: document.getElementById("authBypass").checked, authHeaderName: document.getElementById("authHeaderName").value.trim() || "X-Remote-User", }, enableWebDAV: document.getElementById("enableWebDAV").checked, sharedMaxUploadSize: parseInt(document.getElementById("sharedMaxUploadSize").value || "0", 10) || 0, oidc: { providerUrl: document.getElementById("oidcProviderUrl").value.trim(), redirectUri: document.getElementById("oidcRedirectUri").value.trim(), // clientId/clientSecret: only include when replacing }, globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(), branding: { customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(), headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(), headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(), }, }; const idEl = document.getElementById("oidcClientId"); const scEl = document.getElementById("oidcClientSecret"); const idVal = idEl?.value.trim() || ''; const secVal = scEl?.value.trim() || ''; const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); // no saved value yet const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); // no saved value yet if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') { payload.oidc.clientId = idVal; } if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') { payload.oidc.clientSecret = secVal; } const ooSecretEl = document.getElementById("ooJwtSecret"); payload.onlyoffice = { enabled: document.getElementById("ooEnabled").checked, docsOrigin: document.getElementById("ooDocsOrigin").value.trim() }; if (ooSecretEl?.dataset.replace === '1' && ooSecretEl.value.trim() !== '') { payload.onlyoffice.jwtSecret = ooSecretEl.value.trim(); } // ---- ONLYOFFICE payload ---- if (!window.__OO_LOCKED) { const ooSecretVal = (document.getElementById("ooJwtSecret")?.value || "").trim(); payload.onlyoffice = { enabled: document.getElementById("ooEnabled").checked, docsOrigin: document.getElementById("ooDocsOrigin").value.trim() }; // If user typed a secret (non-empty), send it (server keeps it if non-empty) if (ooSecretVal !== "") { payload.onlyoffice.jwtSecret = ooSecretVal; } } fetch('/api/admin/updateConfig.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '') }, body: JSON.stringify(payload) }) .then(r => r.json()) .then(j => { if (j.error) { showToast('Error: ' + j.error); return; } showToast('Settings saved.'); closeAdminPanel(); applyHeaderColorsFromAdmin(); updateHeaderLogoFromAdmin(); }) .catch(() => showToast('Save failed.')); } export async function closeAdminPanel() { if (hasUnsavedChanges()) { //const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); //if (!ok) return; } const m = document.getElementById("adminPanelModal"); if (m) m.style.display = "none"; } /* =========================== New: Folder Access (ACL) UI =========================== */ let __allFoldersCache = null; async function getAllFolders(force = false) { if (!force && __allFoldersCache) return __allFoldersCache.slice(); const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-store' } }); const data = await safeJson(res).catch(() => []); const list = Array.isArray(data) ? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean) : []; const hidden = new Set(['profile_pics', 'trash']); const cleaned = list .filter(f => f && !hidden.has(f.toLowerCase())) .sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b))); __allFoldersCache = cleaned; return cleaned.slice(); } async function getUserGrants(username) { const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, { credentials: 'include' }); const data = await safeJson(res).catch(() => ({})); return (data && data.grants) ? data.grants : {}; } function computeGroupGrantMaskForUser(username) { const result = {}; const uname = (username || "").trim().toLowerCase(); if (!uname) return result; if (!__groupsCache || typeof __groupsCache !== "object") return result; Object.keys(__groupsCache).forEach(gName => { const g = __groupsCache[gName] || {}; const members = Array.isArray(g.members) ? g.members : []; const isMember = members.some(m => String(m || "").trim().toLowerCase() === uname); if (!isMember) return; const grants = g.grants && typeof g.grants === "object" ? g.grants : {}; Object.keys(grants).forEach(folder => { const fg = grants[folder]; if (!fg || typeof fg !== "object") return; if (!result[folder]) result[folder] = {}; Object.keys(fg).forEach(capKey => { if (fg[capKey]) { result[folder][capKey] = true; } }); }); }); return result; } function applyGroupLocksForUser(username, grantsBox, groupMask, groupsForUser) { if (!grantsBox || !groupMask) return; const groupLabels = (groupsForUser || []).map(name => { const g = __groupsCache && __groupsCache[name] || {}; return g.label || name; }); const labelStr = groupLabels.join(", "); const rows = grantsBox.querySelectorAll(".folder-access-row"); rows.forEach(row => { const folder = row.dataset.folder || ""; const capsForFolder = groupMask[folder]; if (!capsForFolder) return; Object.keys(capsForFolder).forEach(capKey => { if (!capsForFolder[capKey]) return; // Map caps to actual columns we have in the UI let uiCaps = []; switch (capKey) { case "view": case "viewOwn": case "manage": case "create": case "upload": case "edit": case "rename": case "copy": case "move": case "delete": case "extract": case "shareFile": case "shareFolder": uiCaps = [capKey]; break; case "write": uiCaps = ["create", "upload", "edit", "rename", "copy", "delete", "extract"]; break; case "share": uiCaps = ["shareFile", "shareFolder"]; break; default: // unknown / unsupported cap key in UI return; } uiCaps.forEach(c => { const cb = row.querySelector(`input[type="checkbox"][data-cap="${c}"]`); if (!cb) return; cb.checked = true; cb.disabled = true; cb.setAttribute("data-hard-disabled", "1"); let baseTitle = "Granted via group"; if (groupLabels.length > 1) baseTitle += "s"; if (labelStr) baseTitle += `: ${labelStr}`; cb.title = baseTitle + ". Edit group permissions in User groups to change."; }); }); }); } function renderFolderGrantsUI(username, container, folders, grants) { container.innerHTML = ""; // toolbar const toolbar = document.createElement('div'); toolbar.className = 'folder-access-toolbar'; toolbar.innerHTML = ` (${tf('applies_to_filtered', 'applies to filtered list')}) `; container.appendChild(toolbar); const list = document.createElement('div'); list.className = 'folder-access-list'; container.appendChild(list); const headerHtml = `
${tf('folder', 'Folder')}
${tf('view_all', 'View (all)')}
${tf('view_own', 'View (own)')}
${tf('write_full', 'Write')}
${tf('manage', 'Manage')}
${tf('create', 'Create File')}
${tf('upload', 'Upload File')}
${tf('edit', 'Edit File')}
${tf('rename', 'Rename File')}
${tf('copy', 'Copy File')}
${tf('delete', 'Delete File')}
${tf('extract', 'Extract ZIP')}
${tf('share_file', 'Share File')}
${tf('share_folder', 'Share Folder')}
`; function rowHtml(folder) { const g = grants[folder] || {}; const name = folder === 'root' ? '(Root)' : folder; const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.delete || g.extract); const shareFolderDisabled = !g.view; return `
folder ${name}
`; } function setRowDisabled(row, disabled) { qsa(row, 'input[type="checkbox"]').forEach(cb => { cb.disabled = disabled || cb.hasAttribute('data-hard-disabled'); }); row.classList.toggle('inherited-row', !!disabled); const tag = row.querySelector('.inherited-tag'); if (tag) tag.style.display = disabled ? 'inline-block' : 'none'; } function refreshInheritance() { const rows = qsa(list, '.folder-access-row').sort((a, b) => (a.dataset.folder || '').length - (b.dataset.folder || '').length); const managedPrefixes = new Set(); rows.forEach(row => { const folder = row.dataset.folder || ""; const manage = qs(row, 'input[data-cap="manage"]'); if (manage && manage.checked) managedPrefixes.add(folder); let inheritedFrom = null; for (const p of managedPrefixes) { if (p && folder !== p && folder.startsWith(p + '/')) { inheritedFrom = p; break; } } if (inheritedFrom) { const v = qs(row, 'input[data-cap="view"]'); const w = qs(row, 'input[data-cap="write"]'); const vo = qs(row, 'input[data-cap="viewOwn"]'); if (v) v.checked = true; if (w) w.checked = true; if (vo) { vo.checked = false; vo.disabled = true; } ['create', 'upload', 'edit', 'rename', 'copy', 'delete', 'extract', 'shareFile', 'shareFolder'] .forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; }); setRowDisabled(row, true); const tag = row.querySelector('.inherited-tag'); if (tag) tag.textContent = `(${tf('inherited', 'inherited')} ${tf('from', 'from')} ${inheritedFrom})`; } else { setRowDisabled(row, false); } enforceShareFolderRule(row); const cbView = qs(row, 'input[data-cap="view"]'); const cbViewOwn = qs(row, 'input[data-cap="viewOwn"]'); if (cbView && cbViewOwn) { if (cbView.checked) { cbViewOwn.checked = false; cbViewOwn.disabled = true; cbViewOwn.title = tf('full_view_supersedes_own', 'Full view supersedes own-only'); } else { cbViewOwn.disabled = false; cbViewOwn.removeAttribute('title'); } } }); } function setFromViewChange(row, which, checked) { if (!checked && (which === 'view' || which === 'viewOwn')) { qsa(row, 'input[type="checkbox"]').forEach(cb => cb.checked = false); } const cbView = qs(row, 'input[data-cap="view"]'); const cbVO = qs(row, 'input[data-cap="viewOwn"]'); if (cbView && cbVO) { if (cbView.checked) { cbVO.checked = false; cbVO.disabled = true; cbVO.title = tf('full_view_supersedes_own', 'Full view supersedes own-only'); } else { cbVO.disabled = false; cbVO.removeAttribute('title'); } } enforceShareFolderRule(row); } function wireRow(row) { const cbView = row.querySelector('input[data-cap="view"]'); const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]'); const cbWrite = row.querySelector('input[data-cap="write"]'); const cbManage = row.querySelector('input[data-cap="manage"]'); const cbCreate = row.querySelector('input[data-cap="create"]'); const cbUpload = row.querySelector('input[data-cap="upload"]'); const cbEdit = row.querySelector('input[data-cap="edit"]'); const cbRename = row.querySelector('input[data-cap="rename"]'); const cbCopy = row.querySelector('input[data-cap="copy"]'); const cbMove = row.querySelector('input[data-cap="move"]'); const cbDelete = row.querySelector('input[data-cap="delete"]'); const cbExtract = row.querySelector('input[data-cap="extract"]'); const cbShareF = row.querySelector('input[data-cap="shareFile"]'); const cbShareFo = row.querySelector('input[data-cap="shareFolder"]'); const granular = [cbCreate, cbUpload, cbEdit, cbRename, cbCopy, cbMove, cbDelete, cbExtract]; const applyManage = () => { if (cbManage && cbManage.checked) { if (cbView) cbView.checked = true; if (cbWrite) cbWrite.checked = true; granular.forEach(cb => { if (cb) cb.checked = true; }); if (cbShareF) cbShareF.checked = true; if (cbShareFo && !cbShareFo.disabled) cbShareFo.checked = true; } }; const syncWriteFromGranular = () => { if (!cbWrite) return; cbWrite.checked = granular.some(cb => cb && cb.checked); }; const applyWrite = () => { if (!cbWrite) return; granular.forEach(cb => { if (cb) cb.checked = cbWrite.checked; }); const any = granular.some(cb => cb && cb.checked); if (any && cbView && !cbView.checked && cbViewOwn && !cbViewOwn.checked) cbViewOwn.checked = true; }; const onShareFile = () => { if (cbShareF && cbShareF.checked && cbView && !cbView.checked && cbViewOwn && !cbViewOwn.checked) { cbViewOwn.checked = true; } }; const cascadeManage = (checked) => { const base = row.dataset.folder || ""; if (!base) return; qsa(container, '.folder-access-row').forEach(r => { const f = r.dataset.folder || ""; if (!f || f === base) return; if (!f.startsWith(base + '/')) return; const m = r.querySelector('input[data-cap="manage"]'); const v = r.querySelector('input[data-cap="view"]'); const w = r.querySelector('input[data-cap="write"]'); const vo = r.querySelector('input[data-cap="viewOwn"]'); const boxes = [ 'create', 'upload', 'edit', 'rename', 'copy', 'delete', 'extract', 'shareFile', 'shareFolder' ].map(c => r.querySelector(`input[data-cap="${c}"]`)); if (m) m.checked = checked; if (v) v.checked = checked; if (w) w.checked = checked; if (vo) { vo.checked = false; vo.disabled = checked; } boxes.forEach(b => { if (b) b.checked = checked; }); enforceShareFolderRule(r); }); refreshInheritance(); }; if (cbManage) cbManage.addEventListener('change', () => { applyManage(); onShareFile(); cascadeManage(cbManage.checked); }); if (cbWrite) cbWrite.addEventListener('change', applyWrite); granular.forEach(cb => { if (cb) cb.addEventListener('change', () => { syncWriteFromGranular(); }); }); if (cbView) cbView.addEventListener('change', () => { setFromViewChange(row, 'view', cbView.checked); refreshInheritance(); }); if (cbViewOwn) cbViewOwn.addEventListener('change', () => { setFromViewChange(row, 'viewOwn', cbViewOwn.checked); refreshInheritance(); }); if (cbShareF) cbShareF.addEventListener('change', onShareFile); if (cbShareFo) cbShareFo.addEventListener('change', () => onShareFolderToggle(row, cbShareFo.checked)); applyManage(); enforceShareFolderRule(row); syncWriteFromGranular(); } function render(filter = "") { const f = filter.trim().toLowerCase(); const rowsHtml = folders .filter(x => !f || x.toLowerCase().includes(f)) .map(rowHtml) .join(""); list.innerHTML = headerHtml + rowsHtml; list.querySelectorAll('.folder-access-row').forEach(wireRow); refreshInheritance(); } render(); const filterInput = toolbar.querySelector('input[type="text"]'); filterInput.addEventListener('input', () => render(filterInput.value)); toolbar.querySelectorAll('input[type="checkbox"][data-bulk]').forEach(bulk => { bulk.addEventListener('change', () => { const which = bulk.dataset.bulk; const f = (filterInput.value || "").trim().toLowerCase(); list.querySelectorAll('.folder-access-row').forEach(row => { const folder = row.dataset.folder || ""; if (f && !folder.toLowerCase().includes(f)) return; const target = row.querySelector(`input[data-cap="${which}"]`); if (!target) return; target.checked = bulk.checked; if (which === 'manage') { target.dispatchEvent(new Event('change')); } else if (which === 'share') { if (bulk.checked) { const v = row.querySelector('input[data-cap="view"]'); if (v) v.checked = true; } } else if (which === 'write') { onWriteToggle(row, bulk.checked); } else if (which === 'view' || which === 'viewOwn') { setFromViewChange(row, which, bulk.checked); } enforceShareFolderRule(row); }); refreshInheritance(); }); }); } function collectGrantsFrom(container) { const out = {}; const get = (row, sel) => { const el = row.querySelector(sel); return el ? !!el.checked : false; }; container.querySelectorAll('.folder-access-row').forEach(row => { const folder = row.dataset.folder || row.getAttribute('data-folder'); if (!folder) return; const g = { view: get(row, 'input[data-cap="view"]'), viewOwn: get(row, 'input[data-cap="viewOwn"]'), manage: get(row, 'input[data-cap="manage"]'), create: get(row, 'input[data-cap="create"]'), upload: get(row, 'input[data-cap="upload"]'), edit: get(row, 'input[data-cap="edit"]'), rename: get(row, 'input[data-cap="rename"]'), copy: get(row, 'input[data-cap="copy"]'), move: get(row, 'input[data-cap="move"]'), delete: get(row, 'input[data-cap="delete"]'), extract: get(row, 'input[data-cap="extract"]'), shareFile: get(row, 'input[data-cap="shareFile"]'), shareFolder: get(row, 'input[data-cap="shareFolder"]') }; g.share = !!(g.shareFile || g.shareFolder); out[folder] = g; }); return out; } export function openUserPermissionsModal() { let userPermissionsModal = document.getElementById("userPermissionsModal"); const isDarkMode = document.body.classList.contains("dark-mode"); const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const modalContentStyles = ` background: ${isDarkMode ? "#2c2c2c" : "#fff"}; color: ${isDarkMode ? "#e0e0e0" : "#000"}; padding: 20px; width: clamp(980px, 92vw, 1280px); max-width: none; position: relative; max-height: 90vh; overflow: auto; `; if (!userPermissionsModal) { userPermissionsModal = document.createElement("div"); userPermissionsModal.id = "userPermissionsModal"; userPermissionsModal.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: ${overlayBackground}; display: flex; justify-content: center; align-items: center; z-index: 3500; `; userPermissionsModal.innerHTML = ` `; document.body.appendChild(userPermissionsModal); document.getElementById("closeUserPermissionsModal").addEventListener("click", () => { userPermissionsModal.style.display = "none"; }); document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => { userPermissionsModal.style.display = "none"; }); document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => { const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); const changes = []; rows.forEach(row => { if (row.getAttribute("data-admin") === "1") return; // skip admins const username = String(row.getAttribute("data-username") || "").trim(); if (!username) return; const grantsBox = row.querySelector(".folder-grants-box"); if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return; const grants = collectGrantsFrom(grantsBox); changes.push({ user: username, grants }); }); try { if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; } await sendRequest("/api/admin/acl/saveGrants.php", "POST", { changes }, { "X-CSRF-Token": window.csrfToken || "" } ); showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); userPermissionsModal.style.display = "none"; } catch (err) { console.error(err); showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } }); } else { userPermissionsModal.style.display = "flex"; } loadUserPermissionsList(); } async function fetchAllUsers() { const r = await fetch("/api/getUsers.php", { credentials: "include" }); return await r.json(); } async function fetchAllGroups() { const res = await fetch('/api/pro/groups/list.php', { credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '' } }); const data = await safeJson(res); // backend returns { success, groups: { name: {...} } } return data && typeof data === 'object' && data.groups && typeof data.groups === 'object' ? data.groups : {}; } async function saveAllGroups(groups) { const res = await fetch('/api/pro/groups/save.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken || '' }, body: JSON.stringify({ groups }) }); return await safeJson(res); } let __groupsCache = {}; async function openUserGroupsModal() { const isDark = document.body.classList.contains('dark-mode'); const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; const contentBg = isDark ? '#2c2c2c' : '#fff'; const contentFg = isDark ? '#e0e0e0' : '#000'; const borderCol = isDark ? '#555' : '#ccc'; let modal = document.getElementById('userGroupsModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'userGroupsModal'; modal.style.cssText = ` position:fixed; inset:0; background:${overlayBg}; display:flex; align-items:center; justify-content:center; z-index:3650; `; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById('closeUserGroupsModal').onclick = () => (modal.style.display = 'none'); document.getElementById('cancelUserGroups').onclick = () => (modal.style.display = 'none'); document.getElementById('saveUserGroups').onclick = saveUserGroupsFromUI; document.getElementById('addGroupBtn').onclick = addEmptyGroupRow; } else { modal.style.background = overlayBg; const content = modal.querySelector('.modal-content'); if (content) { content.style.background = contentBg; content.style.color = contentFg; content.style.border = `1px solid ${borderCol}`; } } modal.style.display = 'flex'; await loadUserGroupsList(); } async function loadUserGroupsList(useCacheOnly) { const body = document.getElementById('userGroupsBody'); const status = document.getElementById('userGroupsStatus'); if (!body) return; body.textContent = `${t('loading')}…`; if (status) { status.textContent = ''; status.className = 'small text-muted'; } try { // Users always come fresh (or you could cache if you want) const users = await fetchAllUsers(); let groups; if (useCacheOnly && __groupsCache && Object.keys(__groupsCache).length) { // When we’re just re-rendering after local edits, don’t clobber cache groups = __groupsCache; } else { // Initial load, or explicit refresh – pull from server groups = await fetchAllGroups(); __groupsCache = groups || {}; } const usernames = users .map(u => String(u.username || '').trim()) .filter(Boolean) .sort((a, b) => a.localeCompare(b)); const groupNames = Object.keys(__groupsCache).sort((a, b) => a.localeCompare(b)); if (!groupNames.length) { body.innerHTML = `

${tf('no_groups_defined', 'No groups defined yet. Click “Add group” to create one.')}

`; return; } let html = ''; groupNames.forEach(name => { const g = __groupsCache[name] || {}; const label = g.label || name; const members = Array.isArray(g.members) ? g.members : []; const memberOptions = usernames.map(u => { const sel = members.includes(u) ? 'selected' : ''; return ``; }).join(''); html += `
Hold Ctrl/Cmd to select multiple users.
`; }); body.innerHTML = html; // After: body.innerHTML = html; // Show selected members as chips under each multi-select body.querySelectorAll('select[data-group-field="members"]').forEach(sel => { const chips = document.createElement('div'); chips.className = 'group-members-chips'; chips.style.marginTop = '4px'; sel.insertAdjacentElement('afterend', chips); const renderChips = () => { const names = Array.from(sel.selectedOptions).map(o => o.value); if (!names.length) { chips.innerHTML = `No members selected`; return; } chips.innerHTML = names.map(n => ` ${n} `).join(' '); }; sel.addEventListener('change', renderChips); renderChips(); // initial }); body.querySelectorAll('[data-group-action="delete"]').forEach(btn => { btn.addEventListener('click', () => { const card = btn.closest('[data-group-name]'); const name = card && card.getAttribute('data-group-name'); if (!name) return; if (!confirm(`Delete group "${name}"?`)) return; delete __groupsCache[name]; card.remove(); }); }); body.querySelectorAll('[data-group-action="edit-acl"]').forEach(btn => { btn.addEventListener('click', async () => { const card = btn.closest('[data-group-name]'); if (!card) return; const nameInput = card.querySelector('input[data-group-field="name"]'); const name = (nameInput && nameInput.value || '').trim(); if (!name) { showToast('Enter a group name first.'); return; } await openGroupAclEditor(name); }); }); } catch (e) { console.error(e); body.innerHTML = `

${tf('error_loading_groups', 'Error loading groups')}

`; } } function addEmptyGroupRow() { if (!__groupsCache || typeof __groupsCache !== 'object') { __groupsCache = {}; } let idx = 1; let name = `group${idx}`; while (__groupsCache[name]) { idx += 1; name = `group${idx}`; } __groupsCache[name] = { name, label: name, members: [], grants: {} }; // Re-render using local cache only; don't clobber with server (which is still empty) loadUserGroupsList(true); } async function saveUserGroupsFromUI() { const body = document.getElementById('userGroupsBody'); const status = document.getElementById('userGroupsStatus'); if (!body) return; const cards = body.querySelectorAll('[data-group-name]'); const groups = {}; cards.forEach(card => { const oldName = card.getAttribute('data-group-name') || ''; const nameEl = card.querySelector('input[data-group-field="name"]'); const labelEl = card.querySelector('input[data-group-field="label"]'); const membersSel = card.querySelector('select[data-group-field="members"]'); const name = (nameEl && nameEl.value || '').trim(); if (!name) return; const label = (labelEl && labelEl.value || '').trim() || name; const members = Array.from(membersSel && membersSel.selectedOptions || []).map(o => o.value); const existing = __groupsCache[oldName] || __groupsCache[name] || { grants: {} }; groups[name] = { name, label, members, grants: existing.grants || {} }; }); if (status) { status.textContent = 'Saving groups…'; status.className = 'small text-muted'; } try { const res = await saveAllGroups(groups); if (!res.success) { showToast(res.error || 'Error saving groups'); if (status) { status.textContent = 'Error saving groups.'; status.className = 'small text-danger'; } return; } __groupsCache = groups; if (status) { status.textContent = 'Groups saved.'; status.className = 'small text-success'; } showToast('Groups saved.'); } catch (e) { console.error(e); if (status) { status.textContent = 'Error saving groups.'; status.className = 'small text-danger'; } showToast('Error saving groups', 'error'); } } async function openGroupAclEditor(groupName) { const isDark = document.body.classList.contains('dark-mode'); const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; const contentBg = isDark ? '#2c2c2c' : '#fff'; const contentFg = isDark ? '#e0e0e0' : '#000'; const borderCol = isDark ? '#555' : '#ccc'; let modal = document.getElementById('groupAclModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'groupAclModal'; modal.style.cssText = ` position:fixed; inset:0; background:${overlayBg}; display:flex; align-items:center; justify-content:center; z-index:3700; `; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById('closeGroupAclModal').onclick = () => (modal.style.display = 'none'); document.getElementById('cancelGroupAcl').onclick = () => (modal.style.display = 'none'); document.getElementById('saveGroupAcl').onclick = saveGroupAclFromUI; } else { modal.style.background = overlayBg; const content = modal.querySelector('.modal-content'); if (content) { content.style.background = contentBg; content.style.color = contentFg; content.style.border = `1px solid ${borderCol}`; } } const title = document.getElementById('groupAclTitle'); if (title) title.textContent = `Group folder access: ${groupName}`; const body = document.getElementById('groupAclBody'); if (body) body.textContent = `${t('loading')}…`; modal.dataset.groupName = groupName; modal.style.display = 'flex'; const folders = await getAllFolders(true); const grants = (__groupsCache[groupName] && __groupsCache[groupName].grants) || {}; if (body) { body.textContent = ''; const box = document.createElement('div'); box.className = 'folder-grants-box'; body.appendChild(box); renderFolderGrantsUI(groupName, box, ['root', ...folders.filter(f => f !== 'root')], grants); } } function saveGroupAclFromUI() { const modal = document.getElementById('groupAclModal'); if (!modal) return; const groupName = modal.dataset.groupName; if (!groupName) return; const body = document.getElementById('groupAclBody'); if (!body) return; const box = body.querySelector('.folder-grants-box'); if (!box) return; const grants = collectGrantsFrom(box); if (!__groupsCache[groupName]) { __groupsCache[groupName] = { name: groupName, label: groupName, members: [], grants: {} }; } __groupsCache[groupName].grants = grants; showToast('Group folder access updated. Remember to Save groups.'); modal.style.display = 'none'; } async function fetchAllUserFlags() { const r = await fetch("/api/getUserPermissions.php", { credentials: "include" }); const data = await r.json(); if (data && typeof data === "object") { const map = data.allPermissions || data.permissions || data; if (map && typeof map === "object") { Object.values(map).forEach(u => { if (u && typeof u === "object") delete u.folderOnly; }); } } if (Array.isArray(data)) { const out = {}; data.forEach(u => { if (u.username) out[u.username] = u; }); return out; } if (data && data.allPermissions) return data.allPermissions; if (data && data.permissions) return data.permissions; return data || {}; } function flagRow(u, flags) { const f = flags[u.username] || {}; const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin"; const disabledAttr = isAdmin ? "disabled data-admin='1' title='Admin: full access'" : ""; const note = isAdmin ? " (Admin)" : ""; return ` ${u.username}${note} `; } export async function openUserFlagsModal() { const isDark = document.body.classList.contains("dark-mode"); const overlayBg = isDark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const contentBg = isDark ? "#2c2c2c" : "#fff"; const contentFg = isDark ? "#e0e0e0" : "#000"; const borderCol = isDark ? "#555" : "#ccc"; let modal = document.getElementById("userFlagsModal"); if (!modal) { modal = document.createElement("div"); modal.id = "userFlagsModal"; modal.style.cssText = ` position:fixed; inset:0; background:${overlayBg}; display:flex; align-items:center; justify-content:center; z-index:3600; `; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById("closeUserFlagsModal").onclick = () => (modal.style.display = "none"); document.getElementById("cancelUserFlags").onclick = () => (modal.style.display = "none"); document.getElementById("saveUserFlags").onclick = saveUserFlags; } else { modal.style.background = overlayBg; const content = modal.querySelector(".modal-content"); if (content) { content.style.background = contentBg; content.style.color = contentFg; content.style.border = `1px solid ${borderCol}`; } } modal.style.display = "flex"; loadUserFlagsList(); } async function loadUserFlagsList() { const body = document.getElementById("userFlagsBody"); if (!body) return; body.textContent = `${t("loading")}…`; try { const users = await fetchAllUsers(); const flagsMap = await fetchAllUserFlags(); const rows = users.map(u => flagRow(u, flagsMap)).filter(Boolean).join(""); body.innerHTML = ` ${rows || ``}
${t("user")} ${t("read_only")} ${t("disable_upload")} ${t("can_share")} ${t("bypass_ownership")}
${t("no_users_found")}
`; } catch (e) { console.error(e); body.innerHTML = `
${t("error_loading_users")}
`; } } async function saveUserFlags() { const body = document.getElementById("userFlagsBody"); const rows = body?.querySelectorAll("tbody tr[data-username]") || []; const permissions = []; rows.forEach(tr => { if (tr.getAttribute("data-admin") === "1") return; // don't send admin updates const username = tr.getAttribute("data-username"); const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked; permissions.push({ username, readOnly: get("readOnly"), disableUpload: get("disableUpload"), canShare: get("canShare"), bypassOwnership: get("bypassOwnership") }); }); try { const res = await sendRequest("/api/updateUserPermissions.php", "PUT", { permissions }, { "X-CSRF-Token": window.csrfToken } ); if (res && res.success) { showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); const m = document.getElementById("userFlagsModal"); if (m) m.style.display = "none"; } else { showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } } catch (e) { console.error(e); showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } } async function loadUserPermissionsList() { const listContainer = document.getElementById("userPermissionsList"); if (!listContainer) return; listContainer.innerHTML = `

${t("loading")}…

`; try { // Load users + groups together (folders separately) const [usersRes, groupsMap] = await Promise.all([ fetch("/api/getUsers.php", { credentials: "include" }).then(safeJson), fetchAllGroups().catch(() => ({})) ]); const users = Array.isArray(usersRes) ? usersRes : (usersRes.users || []); const groups = groupsMap && typeof groupsMap === "object" ? groupsMap : {}; if (!users.length && !Object.keys(groups).length) { listContainer.innerHTML = "

" + t("no_users_found") + "

"; return; } // Keep cache in sync with the groups UI __groupsCache = groups || {}; const folders = await getAllFolders(true); const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; // Build map: username -> [groupName, ...] const userGroupMap = {}; Object.keys(groups).forEach(gName => { const g = groups[gName] || {}; const members = Array.isArray(g.members) ? g.members : []; members.forEach(m => { const u = String(m || "").trim(); if (!u) return; if (!userGroupMap[u]) userGroupMap[u] = []; userGroupMap[u].push(gName); }); }); // Clear the container and render sections listContainer.innerHTML = ""; // ==================== // Groups section (top) // ==================== const groupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b)); if (groupNames.length) { const groupHeader = document.createElement("div"); groupHeader.className = "muted"; groupHeader.style.margin = "4px 0 6px"; groupHeader.textContent = tf("groups_header", "Groups"); listContainer.appendChild(groupHeader); groupNames.forEach(name => { const g = groups[name] || {}; const label = g.label || name; const members = Array.isArray(g.members) ? g.members : []; const membersSummary = members.length ? members.join(", ") : tf("no_members", "No members yet"); const row = document.createElement("div"); row.classList.add("user-permission-row", "group-permission-row"); row.setAttribute("data-group-name", name); row.style.padding = "6px 0"; row.innerHTML = ` `; // Safely inject dynamic text: const labelEl = row.querySelector('.group-label'); if (labelEl) { labelEl.textContent = label; // no HTML, just text } const membersEl = row.querySelector('.members-summary'); if (membersEl) { membersEl.textContent = `${tf("members_label", "Members")}: ${membersSummary}`; } const header = row.querySelector(".user-perm-header"); const details = row.querySelector(".user-perm-details"); const caret = row.querySelector(".perm-caret"); const grantsBox = row.querySelector(".folder-grants-box"); // Load this group's folder ACL (from __groupsCache) and show it read-only async function ensureLoaded() { if (grantsBox.dataset.loaded === "1") return; try { const group = __groupsCache[name] || {}; const grants = group.grants || {}; renderFolderGrantsUI( name, grantsBox, orderedFolders, grants ); // Make it clear: edit in User groups → Edit folder access grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.disabled = true; cb.title = tf( "edit_group_acl_in_user_groups", "Group ACL is read-only here. Use User groups → Edit folder access to change it." ); }); grantsBox.dataset.loaded = "1"; } catch (e) { console.error(e); grantsBox.innerHTML = `
${tf("error_loading_group_grants", "Error loading group grants")}
`; } } function toggleOpen() { const willShow = details.style.display === "none"; details.style.display = willShow ? "block" : "none"; header.setAttribute("aria-expanded", willShow ? "true" : "false"); caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; if (willShow) ensureLoaded(); } header.addEventListener("click", toggleOpen); header.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } }); listContainer.appendChild(row); }); // divider between groups and users const hr = document.createElement("hr"); hr.style.margin = "6px 0 10px"; hr.style.border = "0"; hr.style.borderTop = "1px solid rgba(0,0,0,0.08)"; listContainer.appendChild(hr); } // ================= // Users section // ================= const sortedUsers = users.slice().sort((a, b) => { const ua = String(a.username || "").toLowerCase(); const ub = String(b.username || "").toLowerCase(); return ua.localeCompare(ub); }); sortedUsers.forEach(user => { const username = String(user.username || "").trim(); const isAdmin = (user.role && String(user.role) === "1") || username.toLowerCase() === "admin"; const groupsForUser = userGroupMap[username] || []; const groupBadges = groupsForUser.length ? (() => { const labels = groupsForUser.map(gName => { const g = groups[gName] || {}; return g.label || gName; }); return `${tf("member_of_groups", "Groups")}: ${labels.join(", ")}`; })() : ""; const row = document.createElement("div"); row.classList.add("user-permission-row"); row.setAttribute("data-username", username); if (isAdmin) row.setAttribute("data-admin", "1"); row.style.padding = "6px 0"; row.innerHTML = ` `; const header = row.querySelector(".user-perm-header"); const details = row.querySelector(".user-perm-details"); const caret = row.querySelector(".perm-caret"); const grantsBox = row.querySelector(".folder-grants-box"); async function ensureLoaded() { if (grantsBox.dataset.loaded === "1") return; try { let grants; const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; if (isAdmin) { // synthesize full access grants = buildFullGrantsForAllFolders(orderedFolders); renderFolderGrantsUI(user.username, grantsBox, orderedFolders, grants); grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true); } else { const userGrants = await getUserGrants(user.username); renderFolderGrantsUI(user.username, grantsBox, orderedFolders, userGrants); // NEW: overlay group-based grants so you can't uncheck them here const groupMask = computeGroupGrantMaskForUser(user.username); // If you already build a userGroupMap somewhere, you can pass the exact groups; // otherwise we can recompute the list of group names from __groupsCache: const groupsForUser = []; if (__groupsCache && typeof __groupsCache === "object") { Object.keys(__groupsCache).forEach(gName => { const g = __groupsCache[gName] || {}; const members = Array.isArray(g.members) ? g.members : []; if (members.some(m => String(m || "").trim().toLowerCase() === String(user.username || "").trim().toLowerCase())) { groupsForUser.push(gName); } }); } applyGroupLocksForUser(user.username, grantsBox, groupMask, groupsForUser); } grantsBox.dataset.loaded = "1"; } catch (e) { console.error(e); grantsBox.innerHTML = `
${tf("error_loading_user_grants", "Error loading user grants")}
`; } } function toggleOpen() { const willShow = details.style.display === "none"; details.style.display = willShow ? "block" : "none"; header.setAttribute("aria-expanded", willShow ? "true" : "false"); caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; if (willShow) ensureLoaded(); } header.addEventListener("click", toggleOpen); header.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } }); listContainer.appendChild(row); }); } catch (err) { console.error(err); listContainer.innerHTML = "

" + t("error_loading_users") + "

"; } }