Files
FileRise/public/js/adminPanel.js

4746 lines
170 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// adminPanel.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
import { showToast, toggleVisibility, attachEnterKeyListener, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { initAdminStorageSection } from './adminStorage.js?v={{APP_QVER}}';
import { initAdminSponsorSection } from './adminSponsor.js?v={{APP_QVER}}';
import { initOnlyOfficeUI, collectOnlyOfficeSettingsForSave } from './adminOnlyOffice.js?v={{APP_QVER}}';
import { openClientPortalsModal } from './adminPortals.js?v={{APP_QVER}}';
// Ensure OIDC config object always exists
if (!window.currentOIDCConfig || typeof window.currentOIDCConfig !== 'object') {
window.currentOIDCConfig = {};
}
async function loadVirusDetectionLog() {
const tableBody = document.getElementById('virusLogTableBody');
const emptyEl = document.getElementById('virusLogEmpty');
const wrapper = document.getElementById('virusLogWrapper');
if (!wrapper || !tableBody || !emptyEl) return;
// If Pro is not active, we just leave the static "Pro" notice alone.
if (!window.__FR_IS_PRO) {
return;
}
emptyEl.textContent = 'Loading recent detections…';
tableBody.innerHTML = '';
try {
const res = await fetch('/api/admin/virusLog.php?limit=50', {
method: 'GET',
credentials: 'include',
headers: {
'X-CSRF-Token': window.csrfToken || '',
'Accept': 'application/json',
},
});
const data = await safeJson(res);
if (!data || data.ok !== true) {
const msg = (data && (data.error || data.message)) || 'Failed to load detection log.';
emptyEl.textContent = msg;
return;
}
const entries = Array.isArray(data.entries) ? data.entries : [];
if (!entries.length) {
emptyEl.textContent = 'No virus detections have been logged yet.';
return;
}
emptyEl.textContent = 'Tip: hover or click a row to see full ClamAV details.';
tableBody.innerHTML = '';
entries.forEach(row => {
const tr = document.createElement('tr');
// Build a compact ClamAV info summary for tooltip / click
const infoParts = [];
if (row.engine) {
infoParts.push(`Engine: ${row.engine}`);
}
if (
typeof row.exitCode === 'number' ||
(typeof row.exitCode === 'string' && row.exitCode !== '')
) {
infoParts.push(`Exit: ${row.exitCode}`);
}
if (row.source) {
infoParts.push(`Source: ${row.source}`);
}
if (row.message) {
// keep it single-line-ish for tooltip/toast
const msg = String(row.message).replace(/\s+/g, ' ').trim();
if (msg) infoParts.push(`Message: ${msg}`);
}
const infoText = infoParts.join(' • ');
tr.innerHTML = `
<td>${escapeHTML(row.ts || '')}</td>
<td>${escapeHTML(row.user || '')}</td>
<td>${escapeHTML(row.ip || '')}</td>
<td>${escapeHTML(row.file || '')}</td>
<td>${escapeHTML(row.folder || '')}</td>
`;
if (infoText) {
// Native browser tooltip on hover
tr.title = infoText;
// Visual hint that row is interactive
tr.style.cursor = 'pointer';
// Click to show toast with same info
tr.addEventListener('click', () => {
showToast(infoText);
});
}
tableBody.appendChild(tr);
});
} catch (e) {
console.error('Failed to load virus detection log', e);
emptyEl.textContent = 'Failed to load detection log.';
}
}
async function downloadVirusLogCsv() {
const emptyEl = document.getElementById('virusLogEmpty');
if (emptyEl) {
emptyEl.textContent = 'Preparing CSV…';
}
try {
const res = await fetch('/api/admin/virusLog.php?limit=2000&format=csv', {
method: 'GET',
credentials: 'include',
headers: {
'X-CSRF-Token': window.csrfToken || '',
'Accept': 'text/csv,text/plain;q=0.9,*/*;q=0.8',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'filerise-virus-log.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
if (emptyEl && emptyEl.textContent === 'Preparing CSV…') {
emptyEl.textContent = '';
}
} catch (e) {
console.error('Failed to download virus log CSV', e);
if (emptyEl) {
emptyEl.textContent = 'Failed to download CSV.';
}
showToast('Failed to download CSV.', 'error');
}
}
function initVirusLogUI({ isPro }) {
const uploadScope = document.getElementById('uploadContent');
if (!uploadScope) return;
const wrapper = uploadScope.querySelector('#virusLogWrapper');
if (!wrapper) return;
// global hint for loadVirusDetectionLog
window.__FR_IS_PRO = !!isPro;
if (!isPro) {
// Free/core: we just show the static Pro alert text, nothing to wire
return;
}
const refreshBtn = uploadScope.querySelector('#virusLogRefreshBtn');
const downloadBtn = uploadScope.querySelector('#virusLogDownloadCsvBtn');
if (refreshBtn && !refreshBtn.__wired) {
refreshBtn.__wired = true;
refreshBtn.addEventListener('click', (e) => {
e.preventDefault();
loadVirusDetectionLog();
});
}
if (downloadBtn && !downloadBtn.__wired) {
downloadBtn.__wired = true;
downloadBtn.addEventListener('click', (e) => {
e.preventDefault();
downloadVirusLogCsv();
});
}
// Initial load
loadVirusDetectionLog();
}
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.2.1';
function getAdminTitle(isPro, proVersion) {
const corePill = `
<span class="badge badge-pill badge-secondary admin-core-badge">
Core ${version}
</span>
`;
// 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 = `
<span class="badge badge-pill badge-warning admin-pro-badge">
${pvLabel}
</span>
`;
const updateHint = hasUpdate
? `
<a
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
class="badge badge-pill badge-warning admin-pro-badge"
style="cursor:pointer; text-decoration:none; margin-left:4px;">
Pro update available
</a>
`
: '';
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 applyFooterFromAdmin() {
try {
const footerEl = document.getElementById('siteFooter');
if (!footerEl) return;
const val = (document.getElementById('brandingFooterHtml')?.value || '').trim();
if (val) {
// Show raw text in the live preview; HTML will be rendered on real page load
footerEl.textContent = val;
} else {
const year = new Date().getFullYear();
footerEl.innerHTML =
`&copy; ${year}&nbsp;<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`;
}
} catch (e) {
console.warn('Failed to live-update footer 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 <img src="...">
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
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
: '';
const note = hasValue
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
: '';
return `
<div class="form-group">
<label for="${id}">${label}:</label>
<div style="display:flex; gap:8px; align-items:center;">
<input type="${type}" id="${id}" class="form-control" ${disabled} />
${replaceBtn}
</div>
${note}
</div>
`;
}
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 wireOidcTestButton(scope = document) {
const btn = scope.querySelector('#oidcTestBtn');
const statusEl = scope.querySelector('#oidcTestStatus');
if (!btn || !statusEl || btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', async () => {
const urlInput = scope.querySelector('#oidcProviderUrl');
const redirectInput = scope.querySelector('#oidcRedirectUri');
const providerUrl = (urlInput && urlInput.value.trim()) || '';
const redirectUri = (redirectInput && redirectInput.value.trim()) || '';
statusEl.textContent = providerUrl
? `Testing discovery for ${providerUrl}`
: 'Testing saved OIDC configuration…';
statusEl.className = 'small text-muted';
try {
const res = await fetch('/api/admin/oidcTest.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
},
body: JSON.stringify({
providerUrl: providerUrl || null,
redirectUri: redirectUri || null
})
});
const data = await safeJson(res);
if (!data || data.success !== true) {
const msg = (data && (data.error || data.message)) || 'OIDC test failed.';
statusEl.textContent = msg;
statusEl.className = 'small text-danger';
showToast('OIDC test failed: ' + msg, 'error');
return;
}
const parts = [];
const authEndpoint = data.authorization_endpoint || data.authorizationUrl;
const userinfoEndpoint = data.userinfo_endpoint || data.userinfoUrl;
if (data.issuer) parts.push('issuer: ' + data.issuer);
if (authEndpoint) parts.push('auth: ' + authEndpoint);
if (userinfoEndpoint) parts.push('userinfo: ' + userinfoEndpoint);
const summary = parts.length
? 'OK ' + parts.join(' • ')
: 'OK provider discovery succeeded.';
statusEl.textContent = summary;
statusEl.className = 'small text-success';
showToast('OIDC discovery is reachable.');
if (Array.isArray(data.warnings) && data.warnings.length) {
console.warn('OIDC test warnings:', data.warnings);
}
} catch (e) {
console.error('OIDC test error', e);
statusEl.textContent = 'Error: ' + (e && e.message ? e.message : String(e));
statusEl.className = 'small text-danger';
showToast('OIDC test failed see console.', 'error');
}
});
}
function wireClamavTestButton(scope = document) {
const btn = scope.querySelector('#clamavTestBtn');
const statusEl = scope.querySelector('#clamavTestStatus');
if (!btn || !statusEl || btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', async () => {
statusEl.textContent = 'Running ClamAV self-test…';
statusEl.className = 'small text-muted';
try {
const res = await fetch('/api/admin/clamavTest.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
},
body: JSON.stringify({})
});
const data = await safeJson(res).catch(err => {
// safeJson throws on !res.ok, so catch to show a nicer message
console.error('ClamAV test HTTP error', err);
return null;
});
if (!data || data.success !== true) {
const msg = (data && (data.error || data.message)) || 'ClamAV test failed.';
statusEl.textContent = msg;
statusEl.className = 'small text-danger';
showToast(msg, 'error');
return;
}
const cmd = data.command || 'clamscan';
const engine = data.engine || '';
const details = data.details || '';
const parts = [];
parts.push(`OK ${cmd} is reachable`);
if (engine) parts.push(engine);
if (details) parts.push(details);
statusEl.textContent = parts.join(' • ');
statusEl.className = 'small text-success';
showToast('ClamAV self-test succeeded.');
} catch (e) {
console.error('ClamAV test error', e);
statusEl.textContent =
'ClamAV test error: ' + (e && e.message ? e.message : String(e));
statusEl.className = 'small text-danger';
showToast('ClamAV test failed see console.', 'error');
}
});
}
function initVirusLogSection({ isPro }) {
const uploadScope = document.getElementById('uploadContent');
if (!uploadScope) return;
const wrapper = uploadScope.querySelector('#virusLogWrapper');
const shell = uploadScope.querySelector('#virusLogTableShell');
if (!wrapper || !shell) return;
// Let us overlay a Pro banner on top of the table
if (!wrapper.style.position) {
wrapper.style.position = 'relative';
}
// Remove any previous overlays
wrapper.querySelectorAll('.virus-pro-overlay').forEach(el => el.remove());
// --- Free/core: show blurred preview + Pro banner ---
if (!isPro) {
shell.innerHTML = `
<table class="table table-sm mb-1"
style="width:100%; filter: blur(2px); opacity:0.65; pointer-events:none;">
<thead>
<tr>
<th style="white-space:nowrap;">Timestamp (UTC)</th>
<th>User</th>
<th>IP</th>
<th>File</th>
<th>Folder</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-muted small">
Virus detections from the last 30 days would appear here.
</td>
</tr>
</tbody>
</table>
`;
const overlay = document.createElement('div');
overlay.className = 'virus-pro-overlay';
overlay.style.cssText = `
position:absolute;
inset:0;
display:flex;
align-items:center;
justify-content:center;
pointer-events:none;
`;
overlay.innerHTML = `
<div style="
background:rgba(0,0,0,0.78);
color:#fff;
padding:8px 14px;
border-radius:999px;
display:flex;
align-items:center;
gap:8px;
font-size:0.85rem;
">
<span class="badge badge-pill badge-warning">Pro</span>
<span>Virus detection log is available in FileRise Pro.</span>
<a href="https://filerise.net"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-light"
style="pointer-events:auto;">
Learn more
</a>
</div>
`;
wrapper.appendChild(overlay);
return;
}
// --- Pro: load real data from /api/admin/virusLog.php ---
shell.innerHTML = `<div class="small text-muted">Loading virus detection log…</div>`;
(async () => {
try {
const res = await fetch('/api/admin/virusLog.php?limit=200', {
method: 'GET',
credentials: 'include',
headers: {
'X-CSRF-Token': window.csrfToken || ''
}
});
const data = await safeJson(res).catch(err => {
console.error('virusLog HTTP error', err);
return null;
});
if (!data || data.ok === false) {
const msg =
(data && (data.error || data.message)) ||
'Failed to load detection log.';
shell.innerHTML = `<div class="text-danger small">${msg}</div>`;
return;
}
const rows = Array.isArray(data.rows || data.entries || data.data)
? (data.rows || data.entries || data.data)
: [];
if (!rows.length) {
shell.innerHTML = `<div class="small text-muted">No virus detections have been logged yet.</div>`;
return;
}
const escapeCell = (v) => {
if (v === null || v === undefined) return '';
return String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
const normEntry = (e) => {
const tsRaw = e.ts ?? e.timestamp ?? e.time ?? e.when ?? '';
let tsLabel = '';
if (typeof tsRaw === 'number') {
const d = new Date(tsRaw * 1000);
tsLabel = isNaN(d.getTime())
? String(tsRaw)
: d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
} else if (tsRaw) {
const d = new Date(tsRaw);
tsLabel = isNaN(d.getTime())
? String(tsRaw)
: d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
}
return {
ts: tsLabel || '',
user: e.user ?? e.username ?? '',
ip: e.ip ?? e.remote_ip ?? e.remoteIp ?? '',
file: e.file ?? e.filename ?? e.name ?? '',
folder: e.folder ?? e.path ?? e.dir ?? ''
};
};
const normalized = rows.map(normEntry);
let html = `
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="thead-light">
<tr>
<th style="white-space:nowrap;">Timestamp (UTC)</th>
<th>User</th>
<th>IP</th>
<th>File</th>
<th>Folder</th>
</tr>
</thead>
<tbody>
`;
normalized.forEach(entry => {
html += `
<tr>
<td style="white-space:nowrap;">${escapeCell(entry.ts)}</td>
<td>${escapeCell(entry.user)}</td>
<td>${escapeCell(entry.ip)}</td>
<td>${escapeCell(entry.file)}</td>
<td>${escapeCell(entry.folder)}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
shell.innerHTML = html;
} catch (e) {
console.error('virusLog error', e);
shell.innerHTML = `<div class="text-danger small">Error loading detection log. See console for details.</div>`;
}
})();
}
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;
};
function wireOidcDebugSnapshotButton(scope = document) {
const btn = scope.querySelector('#oidcDebugSnapshotBtn');
const box = scope.querySelector('#oidcDebugSnapshot');
if (!btn || !box || btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', async () => {
box.textContent = 'Loading snapshot…';
try {
const res = await fetch('/api/admin/oidcDebugInfo.php', {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
}
});
const data = await safeJson(res).catch(err => {
console.error('oidcDebugInfo HTTP error', err);
return null;
});
if (!data || data.success !== true) {
const msg = (data && (data.error || data.message)) || 'Failed to load OIDC snapshot.';
box.textContent = msg;
showToast(msg, 'error');
return;
}
box.textContent = JSON.stringify(data.info || data.data || data, null, 2);
} catch (e) {
console.error('oidcDebugInfo error', e);
box.textContent = 'Error: ' + (e && e.message ? e.message : String(e));
showToast('Failed to load OIDC snapshot see console.', 'error');
}
});
}
// --- 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 ?? {};
}
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(),
oidcDebugLogging: !!document.getElementById("oidcDebugLogging")?.checked,
oidcRedirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim(),
oidcAllowDemote: !!document.getElementById("oidcAllowDemote")?.checked,
// UI is now “enable” toggles
enableFormLogin: !!document.getElementById("enableFormLogin")?.checked,
enableBasicAuth: !!document.getElementById("enableBasicAuth")?.checked,
enableOIDCLogin: !!document.getElementById("enableOIDCLogin")?.checked,
authBypass: !!document.getElementById("authBypass")?.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(),
brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked,
};
}
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("oidcAllowDemote") !== o.oidcAllowDemote ||
getChk("oidcDebugLogging") !== o.oidcDebugLogging ||
// new enable-toggles
getChk("enableFormLogin") !== o.enableFormLogin ||
getChk("enableBasicAuth") !== o.enableBasicAuth ||
getChk("enableOIDCLogin") !== o.enableOIDCLogin ||
getChk("authBypass") !== o.authBypass ||
getChk("enableWebDAV") !== o.enableWebDAV ||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ||
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "") ||
getChk("clamavScanUploads") !== o.clamavScanUploads
);
}
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;
}
// --- NEW: ask the server what version is now active via getConfig.php ---
let finalVersion = '';
try {
const cfgRes = await fetch('/api/admin/getConfig.php?ts=' + Date.now(), {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' }
});
const cfg = await safeJson(cfgRes).catch(() => null);
const cfgVersion = cfg && cfg.pro && cfg.pro.version;
if (cfgVersion) {
finalVersion = String(cfgVersion);
}
} catch (e) {
// If this fails, just fall back to whatever installProBundle gave us.
console.warn('Failed to refresh config after Pro bundle install', e);
}
if (!finalVersion && data.proVersion) {
finalVersion = String(data.proVersion);
}
const versionText = finalVersion ? ` (version ${finalVersion})` : '';
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
statusEl.className = 'small text-success';
// Clear file input so repeat installs feel "fresh"
try { fileInput.value = ''; } catch (_) { }
// Keep existing behavior: refresh any admin config in the header, etc.
if (typeof loadAdminConfigFunc === 'function') {
loadAdminConfigFunc();
}
setTimeout(() => {
window.location.reload();
}, 800);
} 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);
}
}
let __userFlagsCacheHub = null;
let __userMetaCache = {}; // username -> { isAdmin }
async function getUserFlagsCacheForHub() {
if (!__userFlagsCacheHub) {
__userFlagsCacheHub = await fetchAllUserFlags();
}
return __userFlagsCacheHub;
}
function updateUserMetaCache(list) {
__userMetaCache = {};
(list || []).forEach(u => {
if (!u || !u.username) return;
__userMetaCache[u.username] = {
isAdmin: !!(u.isAdmin || u.admin || u.role === "1" || u.username.toLowerCase() === "admin")
};
});
}
async function renderUserHubFlagsForSelected(modal) {
const flagsHost = modal.querySelector('#adminUserHubFlagsRow');
const selectEl = modal.querySelector('#adminUserHubSelect');
if (!flagsHost || !selectEl) return;
const username = (selectEl.value || "").trim();
if (!username) {
flagsHost.innerHTML = `
<div class="small text-muted">
${tf("select_user_for_flags", "Select a user above to view account-level switches.")}
</div>
`;
return;
}
const flagsCache = await getUserFlagsCacheForHub();
const flags = flagsCache[username] || {};
const meta = __userMetaCache[username] || {};
const isAdmin = !!meta.isAdmin;
const disabledAttr = isAdmin ? 'disabled data-admin="1" title="Admin: full access"' : '';
const adminNote = isAdmin
? `<span class="muted" style="margin-left:4px;">(${tf("admin_full_access", "Admin: full access")})</span>`
: '';
flagsHost.innerHTML = `
<div class="table-responsive">
<table class="table table-sm mb-0" style="width:100%;">
<thead>
<tr>
<th style="width:24%;">${t("user")}</th>
<th class="text-center">${t("read_only")}</th>
<th class="text-center">${t("disable_upload")}</th>
<th class="text-center">${t("can_share")}</th>
<th class="text-center">${t("bypass_ownership")}</th>
</tr>
</thead>
<tbody>
<tr data-username="${escapeHTML(username)}">
<td><strong>${escapeHTML(username)}</strong>${adminNote}</td>
<td class="text-center">
<div class="form-check fr-toggle d-inline-block">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="hubFlagReadOnly"
data-flag="readOnly"
${flags.readOnly ? "checked" : ""}
${disabledAttr}>
<label class="form-check-label" for="hubFlagReadOnly"></label>
</div>
</td>
<td class="text-center">
<div class="form-check fr-toggle d-inline-block">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="hubFlagDisableUpload"
data-flag="disableUpload"
${flags.disableUpload ? "checked" : ""}
${disabledAttr}>
<label class="form-check-label" for="hubFlagDisableUpload"></label>
</div>
</td>
<td class="text-center">
<div class="form-check fr-toggle d-inline-block">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="hubFlagCanShare"
data-flag="canShare"
${flags.canShare ? "checked" : ""}
${disabledAttr}>
<label class="form-check-label" for="hubFlagCanShare"></label>
</div>
</td>
<td class="text-center">
<div class="form-check fr-toggle d-inline-block">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="hubFlagBypassOwnership"
data-flag="bypassOwnership"
${flags.bypassOwnership ? "checked" : ""}
${disabledAttr}>
<label class="form-check-label" for="hubFlagBypassOwnership"></label>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<small class="text-muted d-block mt-1">
${isAdmin
? tf("admin_flags_info", "Admins already have full access. These switches are disabled.")
: tf("user_flags_inline_help", "Changes here are saved immediately for this user.")}
</small>
`;
// Admin row is read-only
if (isAdmin) return;
const row = flagsHost.querySelector('tr[data-username]');
if (!row) return;
const checkboxes = row.querySelectorAll('input[type="checkbox"][data-flag]');
const getFlagsFromRow = () => {
const get = (k) => {
const el = row.querySelector(`input[data-flag="${k}"]`);
return !!(el && el.checked);
};
return {
username,
readOnly: get("readOnly"),
disableUpload: get("disableUpload"),
canShare: get("canShare"),
bypassOwnership: get("bypassOwnership")
};
};
const saveFlags = async () => {
const permissions = [getFlagsFromRow()];
try {
const res = await sendRequest(
"/api/updateUserPermissions.php",
"PUT",
{ permissions },
{ "X-CSRF-Token": window.csrfToken }
);
if (!res || res.success === false) {
const msg = (res && (res.error || res.message)) || tf("error_updating_permissions", "Error updating permissions");
showToast(msg, "error");
return;
}
// keep local cache in sync
const flagsCache = await getUserFlagsCacheForHub();
flagsCache[username] = permissions[0];
showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully"));
} catch (err) {
console.error("save inline flags error", err);
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
}
};
checkboxes.forEach(cb => {
cb.addEventListener("change", () => {
saveFlags();
});
});
}
export function openAdminUserHubModal() {
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 ? "var(--fr-surface-dark)" : "#fff";
const contentFg = isDark ? "#e0e0e0" : "#000";
const borderCol = isDark ? "var(--fr-border-dark)" : "#ccc";
// Local helper so we ALWAYS see something (toast or alert)
const safeToast = (msg, type) => {
try {
if (typeof showToast === "function") {
showToast(msg, 7000);
} else {
alert(msg);
}
} catch (e) {
console.error("showToast failed, falling back to alert", e);
alert(msg);
}
};
let modal = document.getElementById("adminUserHubModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "adminUserHubModal";
modal.style.cssText = `
position:fixed; inset:0;
background:${overlayBg};
display:flex; align-items:center; justify-content:center;
z-index:9999;
`;
modal.innerHTML = `
<div class="modal-content"
style="
background:${contentBg};
color:${contentFg};
padding:16px 18px;
max-width:980px;
width:95%;
position:relative;
border:1px solid ${borderCol};
max-height:90vh;
overflow:auto;
">
<span id="closeAdminUserHub"
class="editor-close-btn"
style="top:8px; right:10px;">&times;</span>
<h3 style="margin-top:0;">
${tf("manage_users", "Manage users")}
</h3>
<p class="muted" style="margin-top:-4px; margin-bottom:10px; font-size:0.9rem;">
${tf(
"manage_users_help",
"Select a user from the list to change their password, delete them, or update account-level flags. Use Add User to create a brand new account."
)}
</p>
<!-- Top row: user select + inline actions -->
<div class="d-flex flex-wrap align-items-center"
style="gap:8px; margin-bottom:12px; position:relative;">
<label for="adminUserHubSelect" style="margin:0; font-weight:500;">
${t("username")}
</label>
<select id="adminUserHubSelect"
class="form-control"
style="min-width:220px; max-width:260px;"></select>
<!-- Add user button + dropdown card anchored right under it -->
<div id="adminUserHubAddWrapper"
style="position:relative; display:inline-block;">
<button type="button"
id="adminUserHubAddBtn"
class="btn btn-success btn-sm">
<i class="material-icons"
style="font-size:16px; vertical-align:middle;">person_add</i>
<span style="vertical-align:middle; margin-left:2px;">
${t("add_user")}
</span>
</button>
<div class="card"
id="adminUserHubAddCard"
style="
position:absolute;
top:110%;
left:0;
min-width:260px;
max-width:320px;
padding:10px;
border-radius:8px;
display:none;
z-index:3700;
box-shadow:0 4px 10px rgba(0,0,0,0.25);
">
<h5 style="font-size:0.95rem; margin-bottom:8px;">
${tf("create_new_user_title", "Create New User")}
</h5>
<form id="adminUserHubAddForm">
<div class="form-group mb-1">
<label for="adminUserHubNewUsername" style="margin-bottom:2px;">
${t("username")}
</label>
<input type="text"
id="adminUserHubNewUsername"
name="username"
class="form-control"
autocomplete="off" />
</div>
<div class="form-group mb-1">
<label for="adminUserHubAddPassword" style="margin-bottom:2px;">
${t("password")}
</label>
<input type="password"
id="adminUserHubAddPassword"
name="password"
class="form-control" />
</div>
<div class="form-group mb-2">
<input type="checkbox"
id="adminUserHubIsAdmin"
name="is_admin" />
<label for="adminUserHubIsAdmin" style="margin-left:4px;">
${t("grant_admin")}
</label>
</div>
<button type="submit"
class="btn btn-primary btn-sm">
${t("save_user")}
</button>
</form>
<small class="text-muted d-block"
style="margin-top:4px; font-size:0.8rem;">
${tf(
"create_user_help",
"New users are created immediately and appear in the dropdown at the top."
)}
</small>
</div>
</div>
<button type="button"
id="adminUserHubDeleteBtn"
class="btn btn-danger btn-sm">
<i class="material-icons"
style="font-size:16px; vertical-align:middle;">person_remove</i>
<span style="vertical-align:middle; margin-left:2px;">
${t("remove_user")}
</span>
</button>
<button type="button"
id="adminUserHubRefresh"
class="btn btn-sm btn-outline-secondary ms-auto">
${tf("refresh", "Refresh")}
</button>
</div>
<small class="text-muted d-block"
style="font-size:0.8rem; margin-bottom:8px;">
${tf(
"user_actions_help_inline",
"Delete, change password, and flags apply to the selected user in the dropdown above."
)}
</small>
<!-- Layout -->
<div id="adminUserHubLayout">
<!-- Change password (selected user) -->
<div class="card" style="padding:10px; border-radius:8px; margin-top:4px;">
<h5 style="font-size:0.95rem; margin-bottom:8px;">
${tf("change_user_password", "Change user password")}
</h5>
<div class="form-group mb-1">
<input type="password"
id="adminUserHubNewPassword"
class="form-control"
data-i18n-placeholder="new_password"
placeholder="${t("new_password") || "New Password"}" />
</div>
<div class="form-group mb-2">
<input type="password"
id="adminUserHubConfirmPassword"
class="form-control"
data-i18n-placeholder="confirm_new_password"
placeholder="${t("confirm_new_password") || "Confirm New Password"}" />
</div>
<button type="button"
id="adminUserHubSavePassword"
class="btn btn-primary btn-sm">
${t("save")}
</button>
<small class="text-muted d-block"
style="margin-top:4px; font-size:0.8rem;">
${tf(
"change_user_password_help",
"Resets the selected users password. Does not require their old password (admin-only)."
)}
</small>
</div>
<!-- User permissions / flags -->
<div class="card" style="padding:10px; border-radius:8px; margin-top:10px;">
<h5 style="font-size:0.95rem; margin-bottom:4px;">
${tf("user_permissions", "User Permissions")}
</h5>
<p class="muted"
style="margin-top:-2px; margin-bottom:6px; font-size:0.85rem;">
${tf(
"user_flags_inline_help_long",
"Account-level switches (read-only, disable upload, can share, bypass ownership) for the selected user. For per-folder ACLs, use Folder Access."
)}
</p>
<div id="adminUserHubFlagsRow"></div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const closeBtn = modal.querySelector("#closeAdminUserHub");
if (closeBtn) {
closeBtn.addEventListener("click", () => {
modal.style.display = "none";
});
}
// ESC closes modal
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display === "flex") {
modal.style.display = "none";
}
});
const selectEl = modal.querySelector("#adminUserHubSelect");
const refreshBtn = modal.querySelector("#adminUserHubRefresh");
const addForm = modal.querySelector("#adminUserHubAddForm");
const addBtn = modal.querySelector("#adminUserHubAddBtn");
const addCard = modal.querySelector("#adminUserHubAddCard");
const delBtn = modal.querySelector("#adminUserHubDeleteBtn");
const pwBtn = modal.querySelector("#adminUserHubSavePassword");
const newUserInput = modal.querySelector("#adminUserHubNewUsername");
const newPassInput = modal.querySelector("#adminUserHubAddPassword");
const newAdminInput = modal.querySelector("#adminUserHubIsAdmin");
const resetNewPwInput = modal.querySelector("#adminUserHubNewPassword");
const resetConfPwInput = modal.querySelector("#adminUserHubConfirmPassword");
const getSelectedUser = () => {
return (selectEl && selectEl.value) ? selectEl.value.trim() : "";
};
if (refreshBtn && selectEl) {
refreshBtn.addEventListener("click", async () => {
await populateAdminUserHubSelect(selectEl);
await renderUserHubFlagsForSelected(modal);
});
}
// "Add user" button toggles the dropdown card under the button
if (addBtn && addCard && newUserInput) {
addCard.style.display = "none";
addBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isHidden =
addCard.style.display === "none" || addCard.style.display === "";
addCard.style.display = isHidden ? "block" : "none";
if (isHidden) {
newUserInput.focus();
}
});
// Clicking outside of the addCard closes it
document.addEventListener("click", (e) => {
if (!modal.contains(e.target)) return;
if (
addCard.style.display === "block" &&
!addCard.contains(e.target) &&
!addBtn.contains(e.target)
) {
addCard.style.display = "none";
}
});
}
// Inline "Add user" form WITH backend error -> toast (handles 422)
if (addForm && newUserInput && newPassInput && newAdminInput && selectEl) {
addForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = newUserInput.value.trim();
const password = newPassInput.value.trim();
const isAdmin = !!newAdminInput.checked;
if (!username || !password) {
safeToast("Username and password are required!", "error");
return;
}
try {
const resp = await fetch("/api/addUser.php", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken || ""
},
body: JSON.stringify({ username, password, isAdmin })
});
let data = null;
try {
data = await resp.json();
} catch {
// non-JSON or empty; leave as null
}
const isError =
!resp.ok ||
!data ||
(data.ok === false) ||
(data.success === false);
if (isError) {
const msg =
(data && (data.error || data.message)) ||
(resp.status === 422
? "Could not create user. Please check username/password."
: "Error: Could not add user");
console.error("Add user failed ", resp.status, data);
safeToast(msg, "error");
return;
}
// success
safeToast("User added successfully!");
newUserInput.value = "";
newPassInput.value = "";
newAdminInput.checked = false;
// hide dropdown after successful create
if (addCard) {
addCard.style.display = "none";
}
await populateAdminUserHubSelect(selectEl);
selectEl.value = username;
if (typeof __userFlagsCacheHub !== "undefined") {
__userFlagsCacheHub = null;
}
await renderUserHubFlagsForSelected(modal);
} catch (err) {
console.error("Add user error", err);
const msg =
err && err.message
? err.message
: "Network error while creating user.";
safeToast(msg, "error");
}
});
}
// Delete user
if (delBtn && selectEl) {
delBtn.addEventListener("click", async () => {
const username = getSelectedUser();
if (!username) {
safeToast("Please select a user first.", "error");
return;
}
const current = (localStorage.getItem("username") || "").trim();
if (current && current === username) {
safeToast(
"You cannot delete the account you are currently logged in as.",
"error"
);
return;
}
const ok = await showCustomConfirmModal(
`Are you sure you want to delete user "${username}"?`
);
if (!ok) return;
try {
const res = await sendRequest(
"/api/removeUser.php",
"POST",
{ username },
{ "X-CSRF-Token": window.csrfToken || "" }
);
if (!res || res.success === false) {
const msg =
(res && (res.error || res.message)) ||
"Error: Could not remove user";
safeToast(msg, "error");
return;
}
safeToast("User removed successfully!");
if (typeof __userFlagsCacheHub !== "undefined") {
__userFlagsCacheHub = null;
}
await populateAdminUserHubSelect(selectEl);
await renderUserHubFlagsForSelected(modal);
} catch (err) {
console.error(err);
const msg =
err && err.message
? err.message
: "Error: Could not remove user";
safeToast(msg, "error");
}
});
}
// Reset password for selected user (admin)
if (pwBtn && resetNewPwInput && resetConfPwInput && selectEl) {
pwBtn.addEventListener("click", async () => {
if (window.__FR_DEMO__) {
safeToast("Password changes are disabled on the public demo.");
return;
}
const username = getSelectedUser();
if (!username) {
safeToast("Please select a user first.", "error");
return;
}
const newPw = resetNewPwInput.value.trim();
const conf = resetConfPwInput.value.trim();
if (!newPw || !conf) {
safeToast("Please fill in both password fields.", "error");
return;
}
if (newPw !== conf) {
safeToast("New passwords do not match.", "error");
return;
}
try {
const res = await sendRequest(
"/api/admin/changeUserPassword.php",
"POST",
{ username, newPassword: newPw },
{ "X-CSRF-Token": window.csrfToken || "" }
);
// Handle both legacy {success:false} and new {ok:false,error:...}
if (!res || res.success === false || res.ok === false) {
const msg =
(res && (res.error || res.message)) ||
"Error changing password. Password must be at least 6 characters.";
safeToast(msg, "error");
return;
}
safeToast("Password updated successfully.");
resetNewPwInput.value = "";
resetConfPwInput.value = "";
} catch (err) {
// If sendRequest throws on non-2xx, e.g. 422, surface backend JSON error
console.error("Change password failed ", err.status, err.data || err);
const msg =
(err &&
err.data &&
(err.data.error || err.data.message)) ||
(err && err.message) ||
"Error changing password. Password must be at least 6 characters.";
safeToast(msg, "error");
}
});
}
// When user selection changes, refresh inline flags row
if (selectEl) {
selectEl.addEventListener("change", () => {
renderUserHubFlagsForSelected(modal);
});
}
// Expose for later calls to re-populate
modal.__populate = async () => {
const sel = modal.querySelector("#adminUserHubSelect");
if (sel) {
await populateAdminUserHubSelect(sel);
if (typeof __userFlagsCacheHub !== "undefined") {
__userFlagsCacheHub = null;
}
await renderUserHubFlagsForSelected(modal);
}
};
} else {
// Update colors/theme if already exists
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";
if (modal.__populate) {
modal.__populate();
}
}
function loadShareLinksSection() {
const container =
document.getElementById("shareLinksList") ||
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 = `<p>${t("no_shared_links_available")}</p>`;
return;
}
let html = `<h5>${t("folder_shares")}</h5><ul>`;
Object.entries(folders).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
html += `
<li>
${lock}<strong>${o.folder}</strong>
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button"
data-key="${token}"
data-type="folder"
class="btn btn-sm btn-link delete-share">🗑️</button>
</li>`;
});
html += `</ul><h5 style="margin-top:1em;">${t("file_shares")}</h5><ul>`;
Object.entries(files).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
html += `
<li>
${lock}<strong>${o.folder}/${o.file}</strong>
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button"
data-key="${token}"
data-type="file"
class="btn btn-sm btn-link delete-share">🗑️</button>
</li>`;
});
html += `</ul>`;
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";
const csrfToken =
(document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken || "");
const body = new URLSearchParams({ token });
fetch(endpoint, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": csrfToken
},
body
})
.then(res => {
if (!res.ok) {
if (res.status === 403) {
// Optional: nicer message when CSRF/session is bad
showToast("Forbidden while deleting share (check CSRF/session).", "error");
}
return Promise.reject(res);
}
return res.json();
})
.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;
}
window.currentOIDCConfig = window.currentOIDCConfig || {};
if (config.oidc && typeof config.oidc === 'object') {
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;
window.__FR_IS_PRO = isPro;
const proType = proInfo.type || '';
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
const proLicense = proInfo.license || '';
// New: richer license metadata from FR_PRO_INFO / backend
const proPlan = proInfo.plan || ''; // e.g. "early_supporter_1x", "personal_yearly"
const proExpiresAt = proInfo.expiresAt || ''; // ISO timestamp string or ""
const proMaxMajor = (
typeof proInfo.maxMajor === 'number'
? proInfo.maxMajor
: (proInfo.maxMajor ? Number(proInfo.maxMajor) : null)
);
const brandingCfg = config.branding || {};
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
const brandingFooterHtml = brandingCfg.footerHtml || "";
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 = `
<div class="modal-content" style="${inner}">
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${getAdminTitle(isPro, proVersion)}</h3>
<form id="adminPanelForm">
${[
{ id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: tf("header_footer_settings", "Header & Footer settings") },
{ id: "loginOptions", label: t("login_webdav") },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "upload", label: tf("antivirus_settings", "Antivirus") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links_size") },
{ id: "storage", label: "Storage / Disk Usage" },
{ id: "pro", label: "FileRise Pro" },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i>
</div>
<div id="${sec.id}Content" class="section-content"></div>
`).join("")}
<div class="action-row">
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveAdminSettings" class="btn btn-primary">${t("save_settings")}</button>
</div>
</form>
</div>
`;
document.body.appendChild(mdl);
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
[
"userManagement",
"headerSettings",
"loginOptions",
"onlyoffice",
"upload",
"oidc",
"shareLinks",
"storage",
"pro",
"sponsor"
].forEach(id => {
const headerEl = document.getElementById(id + "Header");
if (!headerEl) return;
headerEl.addEventListener("click", () => toggleSection(id));
});
document.getElementById("userManagementContent").innerHTML = `
<div class="admin-user-actions d-flex flex-wrap" style="gap:8px; margin-bottom:6px;">
<!-- Core: Manage users -->
<button type="button" id="adminOpenUserHub" class="btn btn-primary btn-sm">
<i class="material-icons">people</i>
<span>${tf("manage_users", "Manage users")}</span>
</button>
<!-- Core: Folder Access (per-folder ACLs) -->
<button type="button" id="adminOpenFolderAccess" class="btn btn-secondary btn-sm">
<i class="material-icons">folder_shared</i>
<span>${tf("folder_access", "Folder Access")}</span>
</button>
<!-- Pro: User groups -->
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenUserGroups"
class="btn btn-sm btn-pro-admin"
${!isPro ? "data-pro-locked='1'" : ""}
>
<i class="material-icons">groups</i>
<span>User Groups</span>
</button>
${!isPro ? '<span class="btn-pro-pill">Pro</span>' : ''}
</div>
<!-- Pro: Client Portals -->
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenClientPortal"
class="btn btn-sm btn-pro-admin"
${!isPro ? "data-pro-locked='1'" : ""}
title="Client portals are part of FileRise Pro.">
<i class="material-icons">cloud_upload</i>
<span>Client Portals</span>
</button>
${!isPro ? '<span class="btn-pro-pill">Pro</span>' : ''}
</div>
</div>
<small class="text-muted d-block" style="margin-top:4px;">
Manage users, passwords and account-level flags from “Manage users”.
Use “Folder Access” for per-folder ACLs. User Groups and Client Portals are available in FileRise Pro.
</small>
`;
// Wiring for the 4 buttons
const userHubBtn = document.getElementById("adminOpenUserHub");
if (userHubBtn) {
userHubBtn.addEventListener("click", () => {
openAdminUserHubModal();
});
}
const folderAccessBtn = document.getElementById("adminOpenFolderAccess");
if (folderAccessBtn) {
folderAccessBtn.addEventListener("click", () => {
openUserPermissionsModal();
});
}
const groupsBtn = document.getElementById("adminOpenUserGroups");
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();
});
}
const clientBtn = document.getElementById("adminOpenClientPortal");
if (clientBtn) {
clientBtn.addEventListener("click", () => {
if (!isPro) {
showToast("Client Portals are a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
openClientPortalsModal();
});
}
document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group">
<div class="admin-subsection-title" style="margin-top:2px;">
${t("header_title_text")}
</div>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div>
<hr class="admin-divider">
<!-- Pro: Logo -->
<div class="form-group" style="margin-top:16px;">
<label for="brandingCustomLogoUrl">
<div class="admin-subsection-title" style="margin-top:2px;">
${t("header_logo")}
</div>
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<small class="text-muted d-block mb-1">
${isPro
? 'Upload a logo image or paste a local path.'
: 'Requires FileRise Pro to enable custom header branding.'}
</small>
<div class="input-group mb-2">
<input
type="text"
id="brandingCustomLogoUrl"
class="form-control"
placeholder="/assets/logo.png"
value="${isPro ? (brandingCustomLogoUrl.replace(/"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="input-group">
<input
type="file"
id="brandingLogoFile"
class="form-control"
accept="image/*"
${!isPro ? 'disabled' : ''}
/>
<button
type="button"
class="btn btn-sm btn-secondary"
id="brandingUploadBtn"
${!isPro ? 'disabled' : ''}>
Upload logo
</button>
</div>
</div>
<hr class="admin-divider">
<!-- Pro: Header colors -->
<div class="form-group" style="margin-top:16px;">
<label>
<div class="admin-subsection-title" style="margin-top:2px;">
${t("header_colors")}
</div>
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<div class="d-flex align-items-center" style="gap: 12px; flex-wrap: wrap;">
<div>
<label for="brandingHeaderBgLight" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Light mode</label>
<input
type="color"
id="brandingHeaderBgLight"
value="${brandingHeaderBgLight || '#2196F3'}"
${!isPro ? 'disabled' : ''}
/>
</div>
<div>
<label for="brandingHeaderBgDark" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Dark mode</label>
<input
type="color"
id="brandingHeaderBgDark"
value="${brandingHeaderBgDark || '#1f1f1f'}"
${!isPro ? 'disabled' : ''}
/>
</div>
</div>
<small class="text-muted d-block mt-1">
${isPro
? 'If left empty, FileRise uses its default blue and dark header colors.'
: 'Requires FileRise Pro to enable custom color branding.'}
</small>
</div>
<hr class="admin-divider">
<!-- Pro: Footer text -->
<div class="form-group" style="margin-top:16px;">
<label for="brandingFooterHtml">
<div class="admin-subsection-title" style="margin-top:2px;">
${t("footer_text")}
</div>
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<small class="text-muted d-block mb-1">
${isPro
? 'Shown at the bottom of every page. You can include simple HTML like links.'
: 'Requires FileRise Pro to customize footer text.'}
</small>
<textarea
id="brandingFooterHtml"
class="form-control"
rows="2"
placeholder="&copy; 2025 Your Company. Powered by FileRise."
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}>${isPro ? (brandingFooterHtml || '') : ''}</textarea>
</div>
`;
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 = `
<div class="admin-subsection-title">
${tf("login_options", "Login options")}
</div>
<div class="form-group">
<div class="form-check fr-toggle">
<input
type="checkbox"
class="form-check-input fr-toggle-input"
id="enableFormLogin"
/>
<label class="form-check-label" for="enableFormLogin">
${tf("enable_login_form", "Enable login form")}
</label>
</div>
</div>
<div class="form-group">
<div class="form-check fr-toggle">
<input
type="checkbox"
class="form-check-input fr-toggle-input"
id="enableBasicAuth"
/>
<label class="form-check-label" for="enableBasicAuth">
${tf("enable_basic_http_auth", "Enable HTTP Basic auth")}
</label>
</div>
</div>
<div class="form-group">
<div class="form-check fr-toggle">
<input
type="checkbox"
class="form-check-input fr-toggle-input"
id="enableOIDCLogin"
/>
<label class="form-check-label" for="enableOIDCLogin">
${tf("enable_oidc_login", "Enable OIDC login (OIDC config required)")}
</label>
</div>
</div>
<div class="form-group">
<div class="form-check fr-toggle">
<input
type="checkbox"
class="form-check-input fr-toggle-input"
id="authBypass"
/>
<label class="form-check-label" for="authBypass">
${tf(
"proxy_only_login_label",
"Use proxy header only (disable built-in logins)"
)}
</label>
</div>
<small class="text-muted d-block mt-1">
${tf(
"proxy_only_login_help",
"When enabled, FileRise trusts the reverse proxy header and disables the login form, HTTP Basic and OIDC."
)}
</small>
</div>
<div class="form-group">
<label for="authHeaderName">Auth header name:</label>
<input
type="text"
id="authHeaderName"
class="form-control"
placeholder="e.g. X-Remote-User"
/>
</div>
<hr class="admin-divider">
<div class="admin-subsection-title" style="margin-top:2px;">
WebDAV access
</div>
<div class="form-group">
<div class="form-check fr-toggle">
<input
type="checkbox"
class="form-check-input fr-toggle-input"
id="enableWebDAV"
/>
<label class="form-check-label" for="enableWebDAV">
Enable WebDAV
</label>
</div>
</div>
`;
document.getElementById("uploadContent").innerHTML = `
<div class="admin-subsection-title" style="margin-top:2px;">
Antivirus upload scanning
</div>
<div class="form-group" style="margin-top:10px;">
<div class="form-check fr-toggle">
<input
type="checkbox"
class="form-check-input fr-toggle-input"
id="clamavScanUploads"
/>
<label class="form-check-label" for="clamavScanUploads">
${tf("clamav_enable_label", "Enable ClamAV scanning for uploads")}
</label>
</div>
<small
id="clamavScanUploadsHelp"
class="d-block text-muted"
style="margin-top:2px;"
>
${tf(
"clamav_help_text_short",
"Files are scanned with ClamAV before being accepted. This may impact upload speed."
)}
</small>
</div>
<div class="mt-2">
<button
type="button"
id="clamavTestBtn"
class="btn btn-sm btn-secondary">
${tf("clamav_test_button", "Run ClamAV self-test")}
</button>
<small class="text-muted d-block" style="margin-top:4px;">
${tf(
"clamav_test_help",
"Runs a quick scan against a tiny test file using your configured ClamAV command (VIRUS_SCAN_CMD or clamscan). Safe to run anytime."
)}
</small>
<div id="clamavTestStatus" class="small text-muted" style="margin-top:4px;"></div>
</div>
<hr class="mt-3 mb-2">
${isPro
? `
<!-- Real Pro virus log -->
<div id="virusLogWrapper"
class="card"
style="border-radius: var(--menu-radius); overflow:hidden;">
<div class="card-header py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Virus detection log</strong>
<div class="small text-muted">
Recent uploads that were blocked by ClamAV (username, IP and filename).
</div>
</div>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-sm btn-secondary"
id="virusLogRefreshBtn">
${tf("refresh", "Refresh")}
</button>
<button
type="button"
class="btn btn-sm btn-warning"
id="virusLogDownloadCsvBtn">
${tf("download_csv", "Download CSV")}
</button>
</div>
</div>
</div>
<div class="card-body p-2">
<div class="table-responsive"
style="max-height:220px; overflow:auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th style="width:26%;">Timestamp (UTC)</th>
<th style="width:18%;">User</th>
<th style="width:18%;">IP</th>
<th style="width:24%;">File</th>
<th style="width:14%;">Folder</th>
</tr>
</thead>
<tbody id="virusLogTableBody"></tbody>
</table>
</div>
<div id="virusLogEmpty" class="small text-muted mt-1">
No virus detections have been logged yet.
</div>
</div>
</div>
`
: `
<!-- Pro-style blurred teaser, like Storage explorer -->
<div id="virusLogWrapper"
class="card"
style="border-radius: var(--menu-radius); overflow:hidden; position:relative;">
<div class="card-header py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>
Virus detection log
<span class="badge bg-warning text-dark ms-1 align-middle">Pro</span>
</strong>
<div class="small text-muted">
Recent uploads that were blocked by ClamAV (username, IP and filename).
</div>
</div>
</div>
</div>
<div class="card-body p-2">
<!-- Blurred fake table teaser -->
<div class="table-responsive"
style="max-height:220px;overflow:hidden;filter:blur(3px);opacity:0.5;pointer-events:none;">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Timestamp (UTC)</th>
<th>User</th>
<th>IP</th>
<th>File</th>
<th>Folder</th>
</tr>
</thead>
<tbody>
<tr><td colspan="5">&nbsp;</td></tr>
<tr><td colspan="5">&nbsp;</td></tr>
<tr><td colspan="5">&nbsp;</td></tr>
<tr><td colspan="5">&nbsp;</td></tr>
</tbody>
</table>
</div>
<!-- Centered overlay copy -->
<div
class="d-flex flex-column align-items-center justify-content-center text-center"
style="position:absolute; inset:0; padding:16px;">
<div class="mb-1">
<span class="badge bg-warning text-dark me-1">Pro</span>
<span class="fw-semibold">
Virus detection log is a Pro feature
</span>
</div>
<div class="small text-muted mb-2">
Upgrade to FileRise Pro to view detailed ClamAV detection history
and download it as CSV from the admin panel.
</div>
</div>
</div>
</div>
`
}
`;
wireClamavTestButton(document.getElementById("uploadContent"));
initVirusLogUI({ isPro });
// ONLYOFFICE section (moved into adminOnlyOffice.js)
initOnlyOfficeUI({ config });
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
const oidcDebugEnabled = !!(config.oidc && config.oidc.debugLogging);
const oidcAllowDemote = !!(config.oidc && config.oidc.allowDemote);
document.getElementById("oidcContent").innerHTML = `
<div class="admin-subsection-title" style="margin-top:2px;">
OIDC Configuration
</div>
<div class="form-text text-muted" style="margin-top:8px;">
<small>
Client ID/Secret are never shown after saving. A green note indicates a value is saved.
Click “Replace” to overwrite. For OIDC:
1) create an app in your IdP (Authentik, Keycloak, etc),
2) paste its issuer/base URL below,
3) configure the redirect URI in your IdP,
4) then run the test.
<br><br>
<strong>Security note:</strong>
In production, always configure your IdP and FileRise over
<code>https://</code>. Plain <code>http://</code> should only be used
for local testing or lab environments.
</small>
</div>
<hr class="admin-divider">
<div class="form-group" style="margin-top:8px;">
<label for="oidcProviderUrl">${t("oidc_provider_url")}:</label>
<input type="text" id="oidcProviderUrl" class="form-control"
placeholder="https://idp.example.com/application/o/filerise/"
value="${(window.currentOIDCConfig?.providerUrl || "")}" />
<small class="text-muted">
Use the issuer / base URL from your provider (without the
<code>/.well-known/openid-configuration</code> suffix).
<br>
Avoid <code>http://</code> in production many IdPs and browsers will
block insecure OIDC redirects or set cookies incorrectly.
</small>
</div>
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })}
${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
<div class="form-group">
<label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label>
<input type="text" id="oidcRedirectUri" class="form-control"
placeholder="https://your-filerise-host/auth/oidc/callback"
value="${(window.currentOIDCConfig?.redirectUri || "")}" />
<small class="text-muted">
This must exactly match the redirect/callback URL configured in your IdP application.
</small>
</div>
<hr class="admin-divider">
<div class="form-group" style="margin-top:4px;">
<div class="form-check fr-toggle">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="oidcAllowDemote"
${oidcAllowDemote ? 'checked' : ''} />
<label class="form-check-label" for="oidcAllowDemote">
Allow OIDC to downgrade FileRise admins
</label>
</div>
<small class="text-muted d-block mt-1">
When enabled, if a user loses admin privileges in your IdP, FileRise will also
demote them from admin to regular user on next OIDC login.
<br>
When disabled (default), once a user is an admin in FileRise, role changes in
the IdP will not demote them automatically.
<br>
Container env <code>FR_OIDC_ALLOW_DEMOTE</code> overrides this setting.
</small>
</div>
<hr class="admin-divider">
<div class="form-group">
<label>${tf("oidc_quick_test_label", "Quick OIDC connectivity test")}</label>
<p class="text-muted small mb-1">
This checks that FileRise can reach your providers
<code>/.well-known/openid-configuration</code> endpoint using the URL above.
Save settings first if you changed the URL.
</p>
<button type="button"
class="btn btn-sm btn-secondary"
id="oidcTestBtn">
${tf("oidc_test_button", "Test OIDC discovery")}
</button>
<div id="oidcTestStatus"
class="small text-muted"
style="margin-top:4px;"></div>
</div>
<hr class="admin-divider">
<div class="form-group" style="margin-top:10px;">
<div class="form-check fr-toggle">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="oidcDebugLogging"
${oidcDebugEnabled ? 'checked' : ''} />
<label class="form-check-label" for="oidcDebugLogging">
Enable OIDC debug logging
</label>
</div>
<small class="text-muted d-block mt-1">
When enabled, FileRise logs extra non-sensitive OIDC info to the PHP error log
(issuer, redirect URI, auth method, group counts, etc). Turn this on only while
troubleshooting, then disable it.
</small>
</div>
<hr class="admin-divider">
<div class="form-group" style="margin-top:10px;">
<label>${tf("oidc_debug_snapshot_label", "Effective OIDC configuration snapshot")}</label>
<p class="text-muted small mb-1">
Generates a redacted JSON snapshot (no secrets) of how FileRise sees your OIDC
configuration and environment. Useful to copy/paste into a support ticket.
</p>
<button type="button"
class="btn btn-sm btn-secondary"
id="oidcDebugSnapshotBtn">
${tf("oidc_debug_snapshot_button", "Show snapshot")}
</button>
<pre id="oidcDebugSnapshot"
class="small oidc-debug-snapshot"
style="margin-top:4px; max-height:200px; overflow:auto; padding:6px; border-radius:4px;"></pre>
</div>
<hr class="admin-divider">
<div class="admin-subsection-title" style="margin-top:2px;">
TOTP Configuration
</div>
<div class="form-group">
<label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label>
<input type="text" id="globalOtpauthUrl" class="form-control"
value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
</div>
`;
wireReplaceButtons(document.getElementById("oidcContent"));
wireOidcTestButton(document.getElementById("oidcContent"));
wireOidcDebugSnapshotButton(document.getElementById("oidcContent"));
const shareLinksHost = document.getElementById("shareLinksContent");
if (shareLinksHost) {
shareLinksHost.innerHTML = `
<div class="form-group" style="margin-top:8px;">
<div class="admin-subsection-title" style="margin-top:2px;">
${t("shared_max_upload_size_bytes")}
</div>
<input
type="number"
id="sharedMaxUploadSize"
class="form-control"
placeholder="e.g. 52428800"
/>
<small class="text-muted d-block">
${t("max_bytes_shared_uploads_note")}
</small>
</div>
<hr class="admin-divider">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("manage_shared_links", "Manage shared links")}
</div>
<div id="shareLinksList" class="mt-2">
${t("loading")}
</div>
`;
}
// --- 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);
// Friendly description of plan + lifetime/expiry
let planLabel = '';
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
const mj = proMaxMajor || 1;
planLabel = `Early supporter lifetime for FileRise Pro ${mj}.x`;
} else if (proPlan) {
if (proPlan.startsWith('personal_') || proPlan === 'personal_yearly') {
planLabel = 'Personal license';
} else if (proPlan.startsWith('business_') || proPlan === 'business_yearly') {
planLabel = 'Business license';
} else {
planLabel = proPlan;
}
}
let expiryLabel = '';
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
// Early supporters: we treat as lifetime for that major do NOT show an expiry date
expiryLabel = 'Lifetime license (no expiry)';
} else if (proExpiresAt) {
expiryLabel = `Valid until ${proExpiresAt}`;
}
const proMetaHtml =
isPro && (proType || proEmail || proVersion || planLabel || expiryLabel)
? `
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
<div>
${proType ? `License type: ${proType}` : 'License active'}
${proType && proEmail ? ' • ' : ''}
${proEmail ? `Licensed to: ${proEmail}` : ''}
</div>
${planLabel ? `
<div>
Plan: ${planLabel}
</div>` : ''}
${expiryLabel ? `
<div>
${expiryLabel}
</div>` : ''}
${hasCurrent ? `
<div>
Installed Pro bundle: v${norm(currentVersionRaw)}
</div>` : ''}
${hasLatest ? `
<div>
Latest Pro bundle (UI hint): ${latestVersionRaw}
</div>` : ''}
</div>
`
: '';
proContent.innerHTML = `
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:720px; margin:8px auto;">
<div>
<!-- Title row with pill aligned to "FileRise Pro" -->
<div class="d-flex align-items-center" style="gap:8px;">
<strong>FileRise Pro</strong>
<span class="badge badge-pill ${isPro ? 'badge-success' : 'badge-secondary'} admin-pro-badge">
${isPro ? 'Active' : 'Free'}
</span>
</div>
<!-- Subtitle + meta under the title -->
<div style="font-size:12px; color:#777; margin-top:2px;">
${isPro
? 'Pro features are currently enabled on this instance.'
: 'You are running the free edition. Enter a license key to activate FileRise Pro.'}
</div>
${proMetaHtml}
</div>
${isPro ? `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin d-inline-flex align-items-center"
>
<span>Download latest Pro bundle</span>
${hasUpdate ? `
<span class="badge badge-light" style="margin-left:6px;">
Update available
</span>` : ''}
</a>
<small class="text-muted d-block" style="margin-top:4px;">
Opens filerise.net in a new tab where you can enter your Pro license
to download the latest FileRise Pro ZIP.
</small>
</div>
` : `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/checkout.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin"
>
Buy FileRise Pro
</a>
<small class="text-muted d-block" style="margin-top:4px;">
Opens filerise.net in a new tab so you can purchase a FileRise Pro license.
</small>
</div>
`}
<div class="form-group" style="margin-top:10px;">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="proLicenseInput" style="font-size:12px; margin-bottom:0;">License key</label>
${isPro && proLicense ? `
<button type="button"
class="btn btn-link btn-sm p-0"
id="proCopyLicenseBtn">
Copy current license
</button>
` : ''}
</div>
<textarea
id="proLicenseInput"
class="form-control"
rows="3"
placeholder="Paste your FileRise Pro license key here..."></textarea>
<small class="text-muted">
You can purchase a license at
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
</small>
</div>
<div class="form-group" style="margin-top:6px;">
<label style="font-size:12px;">Or upload license file</label>
<input
type="file"
id="proLicenseFile"
class="form-control-file"
accept=".lic,.json,.txt,.filerise-lic"
/>
<small class="text-muted">
Supported: FileRise.lic, plain text with FRP1... or JSON containing a <code>license</code> field.
</small>
</div>
<button type="button" class="btn btn-primary btn-sm" id="proSaveLicenseBtn" style="margin-top:8px;">
Save license
</button>
<div class="mt-3 border-top pt-3" style="margin-top:14px;">
<h6 class="mb-1">Install / update Pro bundle</h6>
<p class="text-muted small mb-2">
Upload the <code>.zip</code> bundle you downloaded from <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
This runs locally on your server and never contacts an external update service.
</p>
<div class="d-flex flex-wrap align-items-center gap-2" style="margin-top:4px;">
<input type="file"
id="proBundleFile"
accept=".zip"
class="form-control-file mb-2 mb-sm-0" />
<button type="button"
id="btnInstallProBundle"
class="btn btn-sm btn-pro-admin">
Install Pro bundle
</button>
</div>
<div id="proBundleStatus" class="small mt-2"></div>
</div>
</div>
`;
// 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);
const loginToggleIds = ["enableFormLogin", "enableBasicAuth", "enableOIDCLogin"];
const ensureAtLeastOneLogin = (changedEl) => {
const proxyEl = document.getElementById("authBypass");
const proxyOnly = !!proxyEl && proxyEl.checked;
const enabledCount = loginToggleIds
.map(id => document.getElementById(id))
.filter(el => el && el.checked).length;
// If proxy-only is OFF, we require at least one login method
if (!proxyOnly && enabledCount === 0 && changedEl) {
showToast(t("at_least_one_login_method"));
changedEl.checked = true;
}
};
loginToggleIds.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener("change", (e) => {
ensureAtLeastOneLogin(e.target);
});
});
const authBypassEl = document.getElementById("authBypass");
if (authBypassEl) {
authBypassEl.addEventListener("change", (e) => {
const checked = e.target.checked;
if (checked) {
// Proxy-only: switch off all built-in logins
loginToggleIds.forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = false;
});
} else {
// Leaving proxy-only: if everything is off, enable login form by default
const enabledCount = loginToggleIds
.map(id => document.getElementById(id))
.filter(el => el && el.checked).length;
if (enabledCount === 0) {
const fallback = document.getElementById("enableFormLogin");
if (fallback) fallback.checked = true;
}
}
});
}
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);
const loginOpts = config.loginOptions || {};
const formEnabled = !(loginOpts.disableFormLogin === true);
const basicEnabled = !(loginOpts.disableBasicAuth === true);
const oidcEnabled = !(loginOpts.disableOIDCLogin === true);
const proxyOnly = !!loginOpts.authBypass;
document.getElementById("enableFormLogin").checked = formEnabled;
document.getElementById("enableBasicAuth").checked = basicEnabled;
document.getElementById("enableOIDCLogin").checked = oidcEnabled;
document.getElementById("authBypass").checked = proxyOnly;
document.getElementById("authHeaderName").value = loginOpts.authHeaderName || "X-Remote-User";
// If proxy-only is on, force all built-in login toggles off
if (proxyOnly) {
["enableFormLogin", "enableBasicAuth", "enableOIDCLogin"].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = false;
});
}
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// --- ClamAV toggle wiring ---
const cfgClam = config.clamav || {};
const clamChk = document.getElementById("clamavScanUploads");
if (clamChk) {
clamChk.checked = !!cfgClam.scanUploads;
if (cfgClam.lockedByEnv) {
// Env var VIRUS_SCAN_ENABLED is controlling this show as read-only
clamChk.disabled = true;
const help = document.getElementById("clamavScanUploadsHelp");
if (help) {
help.textContent =
'Controlled by container env VIRUS_SCAN_ENABLED (' +
(cfgClam.scanUploads ? 'enabled' : 'disabled') +
'). Change it in your Docker/host env.';
}
}
}
// Rebuild ONLYOFFICE section from fresh config
initOnlyOfficeUI({ config });
captureInitialAdminConfig();
} else {
mdl.style.display = "flex";
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
const loginOpts = config.loginOptions || {};
const formEnabled = !(loginOpts.disableFormLogin === true);
const basicEnabled = !(loginOpts.disableBasicAuth === true);
const oidcEnabled = !(loginOpts.disableOIDCLogin === true);
const proxyOnly = !!loginOpts.authBypass;
document.getElementById("enableFormLogin").checked = formEnabled;
document.getElementById("enableBasicAuth").checked = basicEnabled;
document.getElementById("enableOIDCLogin").checked = oidcEnabled;
document.getElementById("authBypass").checked = proxyOnly;
document.getElementById("authHeaderName").value = loginOpts.authHeaderName || "X-Remote-User";
if (proxyOnly) {
["enableFormLogin", "enableBasicAuth", "enableOIDCLogin"].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = false;
});
}
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// --- ClamAV toggle wiring (refresh) ---
const cfgClam = config.clamav || {};
const clamChk = document.getElementById("clamavScanUploads");
if (clamChk) {
clamChk.checked = !!cfgClam.scanUploads;
// Reset any previous disabled/help, then re-apply
clamChk.disabled = false;
const help = document.getElementById("clamavScanUploadsHelp");
if (help) {
help.textContent =
'Files are scanned with ClamAV before being accepted. This may impact upload speed.';
}
if (cfgClam.lockedByEnv) {
clamChk.disabled = true;
if (help) {
help.textContent =
'Controlled by container env VIRUS_SCAN_ENABLED (' +
(cfgClam.scanUploads ? 'enabled' : 'disabled') +
'). Change it in your Docker/host env.';
}
}
}
wireClamavTestButton(document.getElementById("uploadContent"));
initVirusLogUI({ isPro });
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"));
wireOidcTestButton(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 || '';
const oidcDebugEl = document.getElementById('oidcDebugLogging');
if (oidcDebugEl) {
oidcDebugEl.checked = !!(config.oidc && config.oidc.debugLogging);
}
const oidcAllowDemoteEl = document.getElementById('oidcAllowDemote');
if (oidcAllowDemoteEl) {
oidcAllowDemoteEl.checked = !!(config.oidc && config.oidc.allowDemote);
}
wireOidcDebugSnapshotButton(document.getElementById("oidcContent"));
captureInitialAdminConfig();
}
try {
initAdminStorageSection({
isPro,
modalEl: mdl
});
} catch (e) {
console.error('Failed to init Storage / Disk Usage section', e);
}
try {
initAdminSponsorSection({
container: document.getElementById('sponsorContent'),
t,
tf,
showToast
});
} catch (e) {
console.error('Failed to init Sponsor / Donations section', e);
}
})
.catch(() => {/* if even fetching fails, open empty panel */ });
}
function handleSave() {
const enableFormLogin = !!document.getElementById("enableFormLogin")?.checked;
const enableBasicAuth = !!document.getElementById("enableBasicAuth")?.checked;
const enableOIDCLogin = !!document.getElementById("enableOIDCLogin")?.checked;
const proxyOnlyEnabled = !!document.getElementById("authBypass")?.checked;
const authHeaderName =
(document.getElementById("authHeaderName")?.value || "").trim() ||
"X-Remote-User";
const payload = {
header_title: document.getElementById("headerTitle")?.value || "",
loginOptions: {
// Backend still expects “disable*” flags:
disableFormLogin: !enableFormLogin,
disableBasicAuth: !enableBasicAuth,
disableOIDCLogin: !enableOIDCLogin,
authBypass: proxyOnlyEnabled,
authHeaderName,
},
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(),
debugLogging: !!document.getElementById("oidcDebugLogging")?.checked,
allowDemote: !!document.getElementById("oidcAllowDemote")?.checked,
// clientId/clientSecret added conditionally below
},
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(),
footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
},
clamav: {
scanUploads: document.getElementById("clamavScanUploads").checked,
},
};
// --- OIDC extras (unchanged) ---
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');
const secFirstTime = scEl && !scEl.hasAttribute('data-replace');
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
payload.oidc.clientId = idVal;
}
if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
payload.oidc.clientSecret = secVal;
}
// ONLYOFFICE settings (moved into adminOnlyOffice.js)
collectOnlyOfficeSettingsForSave(payload);
// --- save call (unchanged) ---
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();
applyFooterFromAdmin();
})
.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 = `
<input type="text" class="form-control" style="max-width:220px;"
placeholder="${tf('search_folders', 'Search folders')}" />
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
</label>
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own files)')}
</label>
<label class="muted" title="${tf('write_help', 'File-level: upload, edit, rename, copy, delete, extract ZIPs')}">
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (file ops)')}
</label>
<label class="muted" title="${tf('manage_help', 'Folder-level (owner): can create/rename/move folders and grant access; implies View (all)')}">
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage (folder owner)')}
</label>
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
</label>
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
`;
container.appendChild(toolbar);
const list = document.createElement('div');
list.className = 'folder-access-list';
container.appendChild(list);
const headerHtml = `
<div class="folder-access-header">
<div class="folder-cell" title="${tf('folder_help', 'Folder path within FileRise')}">
${tf('folder', 'Folder')}
</div>
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyones files)')}">
${tf('view_all', 'View (all)')}
</div>
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
${tf('view_own', 'View (own)')}
</div>
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all file-level operations below')}">
${tf('write_full', 'Write')}
</div>
<div class="perm-col" title="${tf('manage_help', 'Folder owner: can create/rename/move folders and grant access; implies View (all)')}">
${tf('manage', 'Manage')}
</div>
<div class="perm-col" title="${tf('create_help', 'Create empty file')}">
${tf('create', 'Create File')}
</div>
<div class="perm-col" title="${tf('upload_help', 'Upload a file into this folder')}">
${tf('upload', 'Upload File')}
</div>
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">
${tf('edit', 'Edit File')}
</div>
<div class="perm-col" title="${tf('rename_help', 'Rename a file')}">
${tf('rename', 'Rename File')}
</div>
<div class="perm-col" title="${tf('copy_help', 'Copy a file')}">
${tf('copy', 'Copy File')}
</div>
<div class="perm-col" title="${tf('delete_help', 'Delete a file')}">
${tf('delete', 'Delete File')}
</div>
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">
${tf('extract', 'Extract ZIP')}
</div>
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">
${tf('share_file', 'Share File')}
</div>
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires Manage + View (all))')}">
${tf('share_folder', 'Share Folder')}
</div>
</div>`;
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 `
<div class="folder-access-row" data-folder="${folder}">
<div class="folder-cell">
<div class="folder-badge">
<i class="material-icons" style="font-size:18px;">folder</i>
${name}
<span class="inherited-tag" style="display:none;"></span>
</div>
</div>
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="write" ${writeMetaChecked ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="manage" ${g.manage ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="create" ${g.create ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="upload" ${g.upload ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="edit" ${g.edit ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="rename" ${g.rename ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="copy" ${g.copy ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="delete" ${g.delete ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="extract" ${g.extract ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="shareFile" ${g.shareFile ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="shareFolder" ${g.shareFolder ? 'checked' : ''} ${shareFolderDisabled ? 'disabled' : ''}></div>
</div>
`;
}
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;
}
async function populateAdminUserHubSelect(selectEl) {
if (!selectEl) return;
selectEl.innerHTML = `<option value="">${tf("loading", "Loading…")}</option>`;
try {
const usersRaw = await fetchAllUsers();
const list = Array.isArray(usersRaw)
? usersRaw
: (usersRaw && Array.isArray(usersRaw.users) ? usersRaw.users : []);
const current = (localStorage.getItem("username") || "").trim();
__userMetaCache = {}; // reset cache
selectEl.innerHTML = "";
const normalized = list
.map(u => {
if (typeof u === "string") return { username: u, isAdmin: false };
return {
username: u.username || u.user || "",
isAdmin: !!(u.isAdmin || u.admin || u.role === "1")
};
})
.filter(u => u.username);
updateUserMetaCache(normalized);
normalized.forEach(u => {
const opt = document.createElement("option");
opt.value = u.username;
opt.textContent = u.isAdmin ? `${u.username} (admin)` : u.username;
if (current && u.username === current) {
opt.dataset.currentUser = "1";
}
selectEl.appendChild(opt);
});
if (!selectEl.value && selectEl.options.length > 0) {
selectEl.selectedIndex = 0;
}
} catch (e) {
console.error("populateAdminUserHubSelect error", e);
selectEl.innerHTML = `<option value="">${tf("error_loading_users", "Error loading users")}</option>`;
}
}
export function openUserPermissionsModal(initialUser = null) {
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: 10000;
`;
userPermissionsModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPermissionsModal" class="editor-close-btn">&times;</span>
<h3>${tf("folder_access", "Folder Access")}</h3>
<div class="muted" style="margin:-4px 0 10px;">
${tf("grant_folders_help", "Grant per-folder capabilities to each user. 'Write/Manage/Share' imply 'View'.")}
</div>
<div id="userPermissionsList" style="max-height: 70vh; overflow-y: auto; margin-bottom: 15px;">
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">${t("save_permissions")}</button>
</div>
</div>
`;
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 = `
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:980px; width:95%;
position:relative;
border:1px solid ${borderCol}; max-height:90vh; overflow:auto;">
<span id="closeUserGroupsModal"
class="editor-close-btn"
style="right:8px; top:8px;">&times;</span>
<h3>User Groups</h3>
<p class="muted" style="margin-top:-6px;">
Define named groups, assign users to them, and attach folder access
just like per-user ACL. Group access is additive to user access.
</p>
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
<button type="button" id="addGroupBtn" class="btn btn-sm btn-success">
<i class="material-icons" style="font-size:16px;">group_add</i>
<span style="margin-left:4px;">Add group</span>
</button>
<span id="userGroupsStatus" class="small text-muted"></span>
</div>
<div id="userGroupsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
${t('loading')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" id="cancelUserGroups" class="btn btn-secondary">${t('cancel')}</button>
<button type="button" id="saveUserGroups" class="btn btn-primary">${t('save_settings')}</button>
</div>
</div>
`;
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 were just re-rendering after local edits, dont 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 = `<p class="muted">${tf('no_groups_defined', 'No groups defined yet. Click “Add group” to create one.')}</p>`;
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 `<option value="${u}" ${sel}>${u}</option>`;
}).join('');
const memberCountLabel = members.length
? `${members.length} member${members.length === 1 ? '' : 's'}`
: 'No members yet';
html += `
<div class="card" data-group-name="${name}" style="margin-bottom:10px; border-radius:8px;">
<!-- Collapsible header -->
<div class="group-card-header d-flex align-items-center"
tabindex="0"
role="button"
aria-expanded="false"
style="gap:6px; padding:6px 10px; cursor:pointer; border-radius:8px;">
<span class="group-caret"
style="display:inline-block; transform:rotate(-90deg); transition:transform 120ms ease;">▸</span>
<i class="material-icons" style="font-size:18px;">group</i>
<strong>${(label || name).replace(/"/g, '&quot;')}</strong>
<span class="muted" style="font-size:0.8rem; margin-left:4px;">
${memberCountLabel}
</span>
<button type="button"
class="btn btn-sm btn-danger group-card-delete"
data-group-action="delete">
<i class="material-icons" style="font-size:22px;">delete</i>
</button>
</div>
<!-- Collapsible body -->
<div class="group-card-body" style="display:none; padding:6px 10px 10px;">
<div class="d-flex align-items-center"
style="gap:6px; flex-wrap:wrap; margin-bottom:6px;">
<label style="margin:0; font-weight:600;">
Group name:
<input type="text"
class="form-control form-control-sm"
data-group-field="name"
value="${name}"
style="display:inline-block; width:160px; margin-left:4px;" />
</label>
<label style="margin:0;">
Label:
<input type="text"
class="form-control form-control-sm"
data-group-field="label"
value="${(g.label || '').replace(/"/g, '&quot;')}"
style="display:inline-block; width:200px; margin-left:4px;" />
</label>
</div>
<div style="margin-top:4px;">
<label style="font-size:12px; font-weight:600;">Members:</label>
<select multiple
class="form-control form-control-sm"
data-group-field="members"
size="${Math.min(Math.max(usernames.length, 3), 8)}">
${memberOptions}
</select>
<small class="text-muted">
Hold Ctrl/Cmd to select multiple users.
</small>
</div>
<div style="margin-top:8px;">
<button type="button"
class="btn btn-sm btn-secondary"
data-group-action="edit-acl">
Edit folder access
</button>
</div>
</div>
</div>
`;
});
body.innerHTML = html;
// Collapse/expand group cards (default: collapsed)
body.querySelectorAll('.card[data-group-name]').forEach(card => {
const header = card.querySelector('.group-card-header');
const bodyEl = card.querySelector('.group-card-body');
const caret = card.querySelector('.group-caret');
if (!header || !bodyEl || !caret) return;
const setExpanded = (expanded) => {
header.setAttribute('aria-expanded', expanded ? 'true' : 'false');
bodyEl.style.display = expanded ? 'block' : 'none';
caret.textContent = expanded ? '▾' : '▸';
};
// Start collapsed
setExpanded(false);
const toggle = () => {
const isOpen = header.getAttribute('aria-expanded') === 'true';
setExpanded(!isOpen);
};
header.addEventListener('click', (e) => {
// Dont toggle when clicking the delete button
if (e.target.closest('[data-group-action="delete"]')) return;
toggle();
});
header.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
});
// 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 = `<span class="muted" style="font-size:11px;">No members selected</span>`;
return;
}
chips.innerHTML = names.map(n => `
<span class="group-member-pill">${n}</span>
`).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 = `<p class="muted">${tf('error_loading_groups', 'Error loading groups')}</p>`;
}
}
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 = `
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:1300px; width:99%;
position:relative;
border:1px solid ${borderCol}; max-height:90vh; overflow:auto;">
<span id="closeGroupAclModal"
class="editor-close-btn"
style="right:8px; top:8px;">&times;</span>
<h3 id="groupAclTitle">Group folder access</h3>
<div class="muted" style="margin:-4px 0 10px;">
Group grants are merged with each members own folder access. They never reduce access.
</div>
<div id="groupAclBody" style="max-height:70vh; overflow-y:auto; margin-bottom:12px;"></div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" id="cancelGroupAcl" class="btn btn-secondary">${t('cancel')}</button>
<button type="button" id="saveGroupAcl" class="btn btn-primary">${t('save_permissions')}</button>
</div>
</div>
`;
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 ? " <span class='muted'>(Admin)</span>" : "";
return `
<tr data-username="${u.username}" ${isAdmin ? "data-admin='1'" : ""}>
<td><strong>${u.username}</strong>${note}</td>
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked" : ""} ${disabledAttr}></td>
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked" : ""} ${disabledAttr}></td>
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked" : ""} ${disabledAttr}></td>
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked" : ""} ${disabledAttr}></td>
</tr>
`;
}
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 = `
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:900px; width:95%;
position:relative;
border:1px solid ${borderCol};">
<span id="closeUserFlagsModal"
class="editor-close-btn"
style="right:8px; top:8px;">&times;</span>
<h3>${tf("user_permissions", "User Permissions")}</h3>
<p class="muted" style="margin-top:-6px;">
${tf("user_flags_help", "Non Admin User Account-level switches. These are NOT per-folder grants.")}
</p>
<div id="userFlagsBody"
style="max-height:60vh; overflow:auto; margin:8px 0;">
${t("loading")}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" id="cancelUserFlags" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveUserFlags" class="btn btn-primary">${t("save_permissions")}</button>
</div>
</div>
`;
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 = `
<table class="table table-sm" style="width:100%;">
<thead>
<tr>
<th>${t("user")}</th>
<th>${t("read_only")}</th>
<th>${t("disable_upload")}</th>
<th>${t("can_share")}</th>
<th>${t("bypass_ownership")}</th>
</tr>
</thead>
<tbody>${rows || `<tr><td colspan="6">${t("no_users_found")}</td></tr>`}</tbody>
</table>
`;
} catch (e) {
console.error(e);
body.innerHTML = `<div class="muted">${t("error_loading_users")}</div>`;
}
}
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 = `<p>${t("loading")}…</p>`;
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 = "<p>" + t("no_users_found") + "</p>";
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 = `
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:12px;">
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
<i class="material-icons" style="font-size:18px;">group</i>
<strong class="group-label"></strong>
<span class="muted" style="margin-left:4px;font-size:11px;">
(${tf("group_label", "group")})
</span>
<span class="muted members-summary" style="margin-left:auto;font-size:11px;"></span>
</div>
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
<div class="folder-grants-box" data-loaded="0"></div>
</div>
`;
// 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 = `<div class="muted">${tf("error_loading_group_grants", "Error loading group grants")}</div>`;
}
}
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 `<span class="muted" style="margin-left:8px;font-size:11px;">${tf("member_of_groups", "Groups")}: ${labels.join(", ")}</span>`;
})()
: "";
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 = `
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:12px;">
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
<i class="material-icons" style="font-size:18px;">person</i>
<strong>${username}</strong>
${groupBadges}
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
</div>
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
<div class="folder-grants-box" data-loaded="0"></div>
</div>
`;
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 = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
}
}
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 = "<p>" + t("error_loading_users") + "</p>";
}
}