// 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
? `Replace `
: '';
const note = hasValue
? `Saved — leave blank to keep `
: '';
return `
`;
}
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")} `;
Object.entries(folders).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
html += `
${lock}${o.folder}
(${new Date(o.expires * 1000).toLocaleString()})
🗑️
`;
});
html += ` ${t("file_shares")} `;
Object.entries(files).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
html += `
${lock}${o.folder}/${o.file}
(${new Date(o.expires * 1000).toLocaleString()})
🗑️
`;
});
html += ` `;
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 = `
×
${getAdminTitle(isPro, proVersion)}
`;
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 = `
person_add
${t("add_user")}
person_remove
${t("remove_user")}
folder_shared
${tf("folder_access", "Folder Access")}
tune
${tf("user_permissions", "User Permissions")}
${
isPro
? `
groups
User groups
`
: `
groups
User groups
Pro
`
}
${
isPro
? `
cloud_upload
Client upload portal
`
: `
cloud_upload
Client upload portal
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 = `
${t("header_title_text")}:
`;
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 = `
${t("disable_login_form")}
${t("disable_basic_http_auth")}
${t("disable_oidc_login")}
Disable all built-in logins (proxy only)
Auth header name:
`;
document.getElementById("webdavContent").innerHTML = `
Enable WebDAV
`;
document.getElementById("uploadContent").innerHTML = `
${t("shared_max_upload_size_bytes")}:
${t("max_bytes_shared_uploads_note")}
`;
// ONLYOFFICE Content
const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret);
window.__HAS_OO_SECRET = hasOOSecret;
document.getElementById("onlyofficeContent").innerHTML = `
Enable ONLYOFFICE integration
Document Server Origin:
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
Run tests
⏳
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
Copy
Select
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.
${t("oidc_provider_url")}:
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })}
${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
${t("oidc_redirect_uri")}:
${t("global_otpauth_url")}:
`;
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)
? `
`
: '';
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 ? `
` : `
Buy FileRise Pro
Opens filerise.net in a new tab so you can purchase a FileRise Pro license.
`}
Or upload license file
Supported: FileRise.lic, plain text with FRP1... or JSON containing a license field.
Save license
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.
Install Pro bundle
`;
// 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 = `
${(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('view_all', 'View (all)')}
${tf('view_own', 'View (own files)')}
${tf('write_full', 'Write (file ops)')}
${tf('manage', 'Manage (folder owner)')}
${tf('share', 'Share')}
(${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 = `
`;
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 `
`;
}
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 = `
×
${tf("folder_access", "Folder Access")}
${tf("grant_folders_help", "Grant per-folder capabilities to each user. 'Write/Manage/Share' imply 'View'.")}
${t("cancel")}
${t("save_permissions")}
`;
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 = `
×
User groups
Define named groups, assign users to them, and attach folder access
just like per-user ACL. Group access is additive to user access.
group_add
Add group
${t('loading')}…
${t('cancel')}
${t('save_settings')}
`;
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 `${u} `;
}).join('');
html += `
Members:
${memberOptions}
Hold Ctrl/Cmd to select multiple users.
Edit folder access
`;
});
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 = `
×
Group folder access
Group grants are merged with each member’s own folder access. They never reduce access.
${t('cancel')}
${t('save_permissions')}
`;
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 = `
×
${tf("user_permissions", "User Permissions")}
${tf("user_flags_help", "Non Admin User Account-level switches. These are NOT per-folder grants.")}
${t("loading")}…
${t("cancel")}
${t("save_permissions")}
`;
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 = `
${t("user")}
${t("read_only")}
${t("disable_upload")}
${t("can_share")}
${t("bypass_ownership")}
${rows || `${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") + "
";
}
}