Files
FileRise/public/js/adminPanel.js
T
Ryan 7b63de5584 release(v3.2.4): OIDC group-claim mapping + extra scopes (Authentik & Keycloak-friendly) + sponsor list update
- OIDC: add configurable group claim + extra scopes (Admin + env overrides)
- OIDC: extract group tags from both userinfo and ID token, supports dot-path claims (e.g. realm_access.roles)
- Admin: surface effective & locked groupClaim + extraScopes values and include them in OIDC debug snapshot
- Docs OpenAPI: document new OIDC config fields
- Admin: add new Pro supporter name to thanks list
2026-01-30 00:29:37 -05:00

7581 lines
302 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 { withBase } from './basePath.js?v={{APP_QVER}}';
import { startTransferProgress, finishTransferProgress } from './transferProgress.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}}';
import {
openUserPermissionsModal,
openUserGroupsModal,
populateAdminUserHubSelect,
fetchAllUsers,
isAdminUser,
computeGroupGrantMaskForUser,
applyGroupLocksForUser
} from './adminFolderAccess.js?v={{APP_QVER}}';
export {
openUserPermissionsModal,
openUserGroupsModal
} from './adminFolderAccess.js?v={{APP_QVER}}';
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.6.0';
const PRO_API_LEVELS = {
diskUsage: 2,
search: 3,
audit: 4,
sources: 5
};
const PRO_API_MIN_VERSION_LABELS = {
diskUsage: '1.2.0',
search: '1.3.0',
audit: '1.4.0',
sources: '1.5.0'
};
const CORE_REQUIRED_PRO_API_LEVEL = Math.max(...Object.values(PRO_API_LEVELS));
const DEFAULT_HEADER_TITLE = 'FileRise';
const PRO_DEFAULT_HEADER_TITLE = 'FileRise Pro';
function resolveHeaderTitle(rawTitle, isPro) {
const cleaned = String(rawTitle || '').trim();
if (!cleaned || cleaned === DEFAULT_HEADER_TITLE) {
return isPro ? PRO_DEFAULT_HEADER_TITLE : DEFAULT_HEADER_TITLE;
}
return cleaned;
}
function compareSemver(a, b) {
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
function startProBundleProgress({ action = 'Updating Pro', title, subText } = {}) {
return startTransferProgress({
action,
title: title || action,
subText: subText || 'Please keep this tab open.',
itemCount: 0,
bytesKnown: false
});
}
function finishProBundleProgress(job, ok, error = '') {
if (!job) return;
finishTransferProgress(job, { ok, error });
}
// 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(t('admin_csv_download_failed'), '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;
}
function getAdminTitle(isPro, proVersion, updatesExpired = false) {
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
? (updatesExpired
? `
<a
href="https://filerise.net/pro/renew.php"
target="_blank"
rel="noopener noreferrer"
id="proUpdatePill"
class="badge badge-pill badge-warning admin-pro-badge"
style="cursor:pointer; text-decoration:none; margin-left:4px;">
Renew to unlock new Pro features
</a>
`
: `
<a
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
id="proUpdatePill"
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;
const sanitizeLogoUrl = (raw) => {
let url = (raw || '').trim();
if (!url) return '';
// If they used a bare "uploads/..." path, normalize to "/uploads/..."
if (!url.startsWith('/') && url.startsWith('uploads/')) {
url = '/' + url;
}
// Strip any CR/LF just in case
url = url.replace(/[\r\n]+/g, '');
if (url.startsWith('/')) {
if (url.includes('://')) return '';
return withBase(url);
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.toString();
} catch (e) {
return '';
}
};
const safeUrl = sanitizeLogoUrl((input && input.value) || '');
if (safeUrl) {
logoImg.setAttribute('src', safeUrl);
logoImg.setAttribute('alt', 'Site logo');
} else {
// fall back to default FileRise logo
logoImg.setAttribute('src', withBase('/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 = resolveHeaderTitle(val, window.__FR_IS_PRO === true);
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 (e) { }
};
// 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(t('admin_oidc_test_failed_detail', { error: 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(t('admin_oidc_discovery_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(t('admin_oidc_test_failed_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(t('admin_clamav_selftest_ok'));
} 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(t('admin_clamav_test_failed_console'), 'error');
}
});
}
function wireResumableCleanupButton(scope = document) {
const btn = scope.querySelector('#resumableCleanupNowBtn');
const statusEl = scope.querySelector('#resumableCleanupStatus');
if (!btn || !statusEl || btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', async () => {
btn.disabled = true;
statusEl.textContent = t('resumable_cleanup_running') || 'Running cleanup...';
statusEl.className = 'small text-muted';
try {
const res = await fetch('/api/admin/resumableCleanup.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
},
body: JSON.stringify({ all: true, purgeAll: true })
});
const data = await safeJson(res);
if (!data || data.success !== true) {
const msg =
(data && (data.error || data.message)) ||
t('resumable_cleanup_failed') ||
'Resumable cleanup failed.';
statusEl.textContent = msg;
statusEl.className = 'small text-danger';
showToast(msg, 'error');
return;
}
const checked = parseInt(data.checked || 0, 10) || 0;
const removed = parseInt(data.removed || 0, 10) || 0;
const remaining = parseInt(data.remaining || 0, 10) || 0;
const sources = parseInt(data.sources || 1, 10) || 1;
const msg = sources > 1
? (t('resumable_cleanup_done_sources', { removed, remaining, checked, sources })
|| `Cleanup complete: removed ${removed}, remaining ${remaining}, checked ${checked} across ${sources} sources.`)
: (t('resumable_cleanup_done', { removed, remaining, checked })
|| `Cleanup complete: removed ${removed}, remaining ${remaining}, checked ${checked}.`);
statusEl.textContent = msg;
statusEl.className = 'small text-success';
showToast(msg, 'success');
} catch (e) {
console.error('Resumable cleanup error', e);
const msg =
(e && e.message ? e.message : '') ||
t('resumable_cleanup_failed') ||
'Resumable cleanup failed.';
statusEl.textContent = msg;
statusEl.className = 'small text-danger';
showToast(msg, 'error');
} finally {
btn.disabled = false;
}
});
}
function wireIgnoreRegexPresetButton(scope = document) {
const btn = scope.querySelector('#ignoreRegexSnapshotsPreset');
const input = scope.querySelector('#ignoreRegex');
if (!btn || !input || btn.__wired) return;
btn.__wired = true;
const preset = '(^|/)(@?snapshots?)(/|$)';
btn.addEventListener('click', (e) => {
e.preventDefault();
if (input.disabled) return;
const current = String(input.value || '');
const hasPreset = current
.replace(/\r\n/g, '\n')
.split('\n')
.some(line => line.trim() === preset);
if (hasPreset) {
input.focus();
return;
}
if (current.trim() === '') {
input.value = preset;
} else {
const suffix = current.endsWith('\n') ? '' : '\n';
input.value = current + suffix + preset;
}
input.focus();
});
}
function renderAdminEncryptionSection({ config, dark }) {
const host = document.getElementById("encryptionContent");
if (!host) return;
const enc = (config && config.encryption && typeof config.encryption === 'object') ? config.encryption : {};
const supported = !!enc.supported;
const hasMasterKey = !!enc.hasMasterKey;
const source = String(enc.source || 'missing');
const lockedByEnv = !!enc.lockedByEnv;
const envPresent = !!enc.envPresent;
const filePresent = !!enc.filePresent;
const canGenerateKey = !lockedByEnv && !filePresent;
const statusPill = (ok, label) => `
<span class="badge badge-pill ${ok ? 'badge-success' : 'badge-secondary'}" style="margin-left:6px;">
${label}
</span>
`;
const sourceLabel = (() => {
if (source === 'env') return 'Env (FR_ENCRYPTION_MASTER_KEY)';
if (source === 'env_invalid') return 'Env present but invalid';
if (source === 'file') return 'Key file (META_DIR/encryption_master.key)';
if (source === 'file_invalid') return 'Key file present but invalid';
return 'Missing';
})();
host.innerHTML = `
<div class="card" style="border:1px solid ${dark ? '#3a3a3a' : '#eaeaea'}; border-radius:10px; padding:12px; background:${dark ? '#1f1f1f' : '#fdfdfd'};">
<div class="d-flex align-items-center" style="gap:10px; margin-bottom:6px;">
<i class="material-icons" aria-hidden="true">enhanced_encryption</i>
<div style="font-weight:600;">
${tf("encryption_at_rest", "Encryption at rest")}
${statusPill(supported, supported ? tf("supported", "Supported") : tf("not_supported", "Not supported"))}
${statusPill(hasMasterKey, hasMasterKey ? tf("configured", "Configured") : tf("missing", "Missing"))}
</div>
</div>
<div class="small text-muted" style="margin-bottom:8px;">
${tf("encryption_help_short", "Folder encryption requires a server master key. Env overrides the key file.")}
</div>
<div class="small" style="line-height:1.5;">
<div><strong>${tf("master_key_source", "Master key source")}:</strong> ${escapeHTML(sourceLabel)}</div>
<div><strong>${tf("env_present", "Env present")}:</strong> ${envPresent ? 'Yes' : 'No'}${lockedByEnv ? ' (locked)' : ''}</div>
<div><strong>${tf("key_file_present", "Key file present")}:</strong> ${filePresent ? 'Yes' : 'No'}</div>
</div>
<hr class="admin-divider" style="margin:10px 0;">
<div class="d-flex flex-wrap" style="gap:8px; align-items:center;">
<button type="button" class="btn btn-sm btn-secondary" id="frEncGenerateKeyBtn" ${canGenerateKey ? '' : 'disabled'}>
${tf("generate_key_file", "Generate key file")}
</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="frEncClearKeyBtn" ${lockedByEnv ? 'disabled' : ''}>
${tf("clear_key_file", "Clear key file")}
</button>
${lockedByEnv ? `<div class="small text-warning">${tf("locked_by_env", "Locked by FR_ENCRYPTION_MASTER_KEY env override.")}</div>` : ''}
</div>
<div class="small text-muted" style="margin-top:8px;">
${tf("encryption_v1_note", "Admin notes:<ul style=\"margin:6px 0 0 18px; padding:0;\"><li>Master key can be set via <code>FR_ENCRYPTION_MASTER_KEY</code> (env overrides the key file) or via <code>META_DIR/encryption_master.key</code> (32 raw bytes).</li><li>Encrypted folders are recursive; shares, shared-folder uploads, WebDAV, and archive create/extract are blocked under encrypted folders.</li><li>Video/audio previews are disabled (no HTTP Range) but users can still download files normally.</li></ul>")}
</div>
</div>
`;
const post = async (action, key, extra = {}) => {
const res = await fetch(withBase('/api/admin/setEncryptionKey.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken || ''
},
body: JSON.stringify({ action, ...(key ? { key } : {}), ...extra })
});
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch (e) { /* ignore */ }
return {
ok: res.ok,
status: res.status,
body: body || {},
raw: text || ''
};
};
const refresh = async () => {
const r = await fetch(withBase('/api/admin/getConfig.php?ts=' + Date.now()), {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' }
});
const next = await safeJson(r);
renderAdminEncryptionSection({ config: next, dark });
};
const genBtn = document.getElementById('frEncGenerateKeyBtn');
if (genBtn && !genBtn.__wired) {
genBtn.__wired = true;
genBtn.addEventListener('click', async () => {
try {
genBtn.disabled = true;
const res = await post('generate');
if (!res.ok) {
throw new Error(res.body?.message || res.body?.error || `HTTP ${res.status}`);
}
showToast(tf("key_file_created", "Key file created."));
await refresh();
} catch (e) {
console.error(e);
showToast((e && e.message) ? e.message : tf("error", "Error"), 'error');
} finally {
genBtn.disabled = !canGenerateKey;
}
});
}
const clearBtn = document.getElementById('frEncClearKeyBtn');
if (clearBtn && !clearBtn.__wired) {
clearBtn.__wired = true;
clearBtn.addEventListener('click', async () => {
try {
clearBtn.disabled = true;
const ok = await showCustomConfirmModal(
"Removing the encryption key file can make encrypted files permanently unreadable. Continue?"
);
if (!ok) {
clearBtn.disabled = lockedByEnv;
return;
}
let res = await post('clear');
if (!res.ok && res.status === 409) {
const errCode = res.body?.error || '';
if (errCode === 'locked_by_env') {
showToast(res.body?.message || t('admin_key_file_locked_env'), 'error');
clearBtn.disabled = lockedByEnv;
return;
}
if (errCode === 'not_supported') {
showToast(res.body?.message || t('admin_encryption_not_supported'), 'error');
clearBtn.disabled = lockedByEnv;
return;
}
const summary = res.body?.summary || {};
const encCount = Number(summary.encryptedCount || 0);
const jobCount = Number(summary.activeJobs || 0);
const scan = summary.scan || null;
const details = [];
if (encCount > 0) details.push(`${encCount} encrypted folder(s).`);
if (jobCount > 0) details.push(`${jobCount} active crypto job(s).`);
if (scan && scan.scanned) {
const scanned = Number(scan.scanned || 0);
if (errCode === 'encrypted_files_detected') {
details.push(`Scan found an encrypted file after checking ${scanned} file(s).`);
} else {
details.push(`Scan checked ${scanned} file(s) for encrypted headers${scan.truncated ? ' (truncated)' : ''}.`);
}
}
const extra = details.length ? details.join('\n') : '';
const reasonLine = (() => {
if (errCode === 'encrypted_files_detected') return 'Encrypted files detected on disk.';
if (errCode === 'encrypted_files_scan_truncated') return 'Encrypted file scan was truncated.';
if (errCode === 'encrypted_files_scan_failed') return 'Encrypted file scan failed.';
if (errCode === 'encrypted_folders_exist') return 'Encrypted folders still exist.';
if (errCode === 'crypto_job_active') return 'An encryption job is still running.';
return '';
})();
const forceOk = await showTypedConfirmModal({
title: "Force remove key file",
message:
"This will permanently break access to encrypted files.\n\n" +
(reasonLine ? reasonLine + "\n\n" : "") +
(extra ? extra + "\n\n" : "") +
(errCode === '' ? "\n\nServer returned 409 without details; assume encrypted data exists." : '') +
"\n\nType REMOVE to confirm.",
confirmText: "REMOVE",
placeholder: "Type REMOVE to continue"
});
if (!forceOk) {
clearBtn.disabled = lockedByEnv;
return;
}
res = await post('clear', null, { force: true });
}
if (!res.ok) {
throw new Error(res.body?.message || res.body?.error || `HTTP ${res.status}`);
}
showToast(tf("key_file_cleared", "Key file cleared."));
await refresh();
} catch (e) {
console.error(e);
showToast((e && e.message) ? e.message : tf("error", "Error"), 'error');
} finally {
clearBtn.disabled = lockedByEnv;
}
});
}
}
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 initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSourcesApiOk }) {
const container = document.getElementById('sourcesContent');
if (!container || container.__initialized) return;
container.__initialized = true;
if (!isPro) {
const isDark = document.body.classList.contains('dark-mode');
const overlayBg = isDark ? 'rgba(15, 23, 42, 0.9)' : 'rgba(255, 255, 255, 0.92)';
const overlayText = isDark ? '#f8fafc' : '#111827';
const overlaySubtext = isDark ? 'rgba(226, 232, 240, 0.9)' : '#4b5563';
const overlayBorder = isDark ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)';
const overlayShadow = isDark ? '0 10px 28px rgba(0,0,0,0.5)' : '0 10px 24px rgba(0,0,0,0.18)';
const title = tf('sources_pro_locked_title', 'Sources are a Pro feature');
const body = tf(
'sources_pro_locked_body',
'Connect remote storage and manage it like local — switch sources, move/copy between them, and keep separate trash per source. Upgrade to FileRise Pro to add S3, SFTP, FTP, WebDAV, SMB, additional Local, Google Drive, Dropbox and OneDrive sources.'
);
const help = tf('sources_help', 'Sources are separate roots; users only see sources they can access.');
const adapterHint = tf(
'sources_adapter_hint',
'Adapters: local, S3, SFTP, FTP, WebDAV, SMB, Google Drive, Dropbox, OneDrive.'
);
container.innerHTML = `
<div 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>
${escapeHTML(tf('sources', 'Sources'))}
<span class="badge bg-warning text-dark ms-1 align-middle">Pro</span>
</strong>
<div class="small text-muted">${escapeHTML(help)}</div>
</div>
</div>
</div>
<div class="card-body p-2">
<div style="filter:blur(3px);opacity:0.5;pointer-events:none;">
<div class="sources-admin">
<div class="form-group" style="margin-bottom:8px;">
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" />
<label class="form-check-label">${escapeHTML(tf('sources_enabled', 'Enable sources'))}</label>
</div>
<small class="text-muted d-block mt-1">${escapeHTML(help)}</small>
</div>
<div class="sources-toolbar">
<button type="button" class="btn btn-sm btn-primary">
${escapeHTML(tf('source_add', 'Add Source'))}
</button>
<button type="button" class="btn btn-sm btn-secondary">
${escapeHTML(tf('refresh', 'Refresh'))}
</button>
</div>
<div class="table-responsive" style="margin-top:6px;">
<table class="table table-sm mb-0">
<thead class="thead-light">
<tr>
<th>${escapeHTML(tf('source_name', 'Source Name'))}</th>
<th>${escapeHTML(tf('source_type', 'Type'))}</th>
<th>${escapeHTML(tf('status', 'Status'))}</th>
</tr>
</thead>
<tbody>
<tr><td>Local</td><td>local</td><td>${escapeHTML(tf('enabled', 'Enabled'))}</td></tr>
<tr><td>Archive</td><td>s3</td><td>${escapeHTML(tf('disabled', 'Disabled'))}</td></tr>
<tr><td>Media</td><td>smb</td><td>${escapeHTML(tf('enabled', 'Enabled'))}</td></tr>
</tbody>
</table>
</div>
<div class="text-muted small" style="margin-top:6px;">
${escapeHTML(adapterHint)}
</div>
</div>
</div>
<div
class="d-flex flex-column align-items-center justify-content-center text-center"
style="position:absolute; inset:0; padding:16px;">
<div style="background:${overlayBg}; color:${overlayText}; border:${overlayBorder}; box-shadow:${overlayShadow}; padding:10px 12px; border-radius:10px; max-width:520px;">
<div class="mb-1">
<span class="badge bg-warning text-dark me-1">Pro</span>
<span class="fw-semibold">${escapeHTML(title)}</span>
</div>
<div class="small mb-2" style="color:${overlaySubtext};">
${escapeHTML(body)}
</div>
</div>
</div>
</div>
</div>
`;
return;
}
if (!proSourcesApiOk) {
const msg = tf(
'sources_pro_bundle_outdated',
`Please upgrade to FileRise Pro v${PRO_API_MIN_VERSION_LABELS.sources}+ to use Sources.`
);
container.innerHTML = `<div class="text-muted">${escapeHTML(msg)}</div>`;
return;
}
container.innerHTML = `
<div class="sources-admin">
<div class="form-group" style="margin-bottom:8px;">
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourcesEnabledToggle" />
<label class="form-check-label" for="sourcesEnabledToggle">
${tf('sources_enabled', 'Enable sources')}
</label>
</div>
<small class="text-muted d-block mt-1">
${tf('sources_help', 'Sources are separate roots; users only see sources they can access.')}
</small>
</div>
<div class="sources-toolbar">
<button type="button" id="sourceAddBtn" class="btn btn-sm btn-primary">
${tf('source_add', 'Add Source')}
</button>
<button type="button" id="sourceRefreshBtn" class="btn btn-sm btn-secondary">
${tf('refresh', 'Refresh')}
</button>
</div>
<div id="sourcesStatus" class="text-muted small" style="margin-bottom:6px;"></div>
<div id="sourcesList" class="sources-list"></div>
<hr class="admin-divider">
<div id="sourcesForm" class="sources-form">
<div class="admin-subsection-title" id="sourceFormTitle">${tf('source_add', 'Add Source')}</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceId">${tf('source_id', 'Source ID')}:</label>
<input type="text" id="sourceId" class="form-control" placeholder="local-main" />
</div>
<div class="form-group">
<label for="sourceName">${tf('source_name', 'Source Name')}:</label>
<input type="text" id="sourceName" class="form-control" placeholder="Local" />
</div>
<div class="form-group">
<label for="sourceType">${tf('source_type', 'Type')}:</label>
<select id="sourceType" class="form-control">
<option value="local">local</option>
<option value="s3">s3</option>
<option value="sftp">sftp</option>
<option value="ftp">ftp</option>
<option value="webdav">webdav</option>
<option value="smb">smb</option>
<option value="gdrive">gdrive</option>
<option value="onedrive">onedrive</option>
<option value="dropbox">dropbox</option>
</select>
</div>
<div class="form-group sources-form-inline">
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourceEnabled" />
<label class="form-check-label" for="sourceEnabled">
${tf('source_enabled', 'Enabled')}
</label>
</div>
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourceReadOnly" />
<label class="form-check-label" for="sourceReadOnly">
${t('read_only')}
</label>
</div>
</div>
</div>
<div class="sources-type-block" data-type="local">
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_local', 'Use an absolute server path. Leave blank to use the default uploads root. Ensure the web user can read/write this path; FileRise does not chown extra mounts.')}">!</button>
</div>
<div class="form-group">
<label for="sourceLocalPath">${tf('source_local_path', 'Local path')}:</label>
<input type="text" id="sourceLocalPath" class="form-control" placeholder="/var/www/uploads/" />
</div>
</div>
<div class="sources-type-block" data-type="s3" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_s3', 'Bucket is required. Region is optional (defaults to us-east-1). Endpoint and path-style are for S3-compatible providers like Wasabi, MinIO, Backblaze B2 (S3 API), DigitalOcean Spaces, or Cloudflare R2. Prefix is optional.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceS3Bucket">${tf('source_s3_bucket', 'S3 bucket')}:</label>
<input type="text" id="sourceS3Bucket" class="form-control" placeholder="my-bucket" />
</div>
<div class="form-group">
<label for="sourceS3Region">${tf('source_s3_region', 'S3 region (optional)')}:</label>
<input type="text" id="sourceS3Region" class="form-control" placeholder="us-east-1" />
</div>
<div class="form-group">
<label for="sourceS3Endpoint">${tf('source_s3_endpoint', 'S3 endpoint')}:</label>
<input type="text" id="sourceS3Endpoint" class="form-control" placeholder="https://s3.amazonaws.com" />
</div>
<div class="form-group">
<label for="sourceS3Prefix">${tf('source_s3_prefix', 'S3 prefix')}:</label>
<input type="text" id="sourceS3Prefix" class="form-control" placeholder="optional/prefix" />
</div>
<div class="form-group">
<label for="sourceS3AccessKey">${tf('source_s3_access_key', 'S3 access key')}:</label>
<input type="text" id="sourceS3AccessKey" class="form-control" autocomplete="off" />
</div>
<div class="form-group">
<label for="sourceS3SecretKey">${tf('source_s3_secret_key', 'S3 secret key')}:</label>
<input type="password" id="sourceS3SecretKey" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceS3SessionToken">${tf('source_s3_session_token', 'S3 session token')}:</label>
<input type="text" id="sourceS3SessionToken" class="form-control" autocomplete="off" />
</div>
</div>
<div class="form-group" style="margin-top:6px;">
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourceS3PathStyle" />
<label class="form-check-label" for="sourceS3PathStyle">
${tf('source_s3_path_style', 'Force path-style addressing')}
</label>
</div>
</div>
</div>
<div class="sources-type-block" data-type="sftp" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_sftp', 'Host and username are required. Use a password or private key. Root is optional; blank uses the login directory.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceSftpHost">${tf('source_sftp_host', 'SFTP host')}:</label>
<input type="text" id="sourceSftpHost" class="form-control" placeholder="sftp.example.com" />
</div>
<div class="form-group">
<label for="sourceSftpPort">${tf('source_sftp_port', 'SFTP port')}:</label>
<input type="number" id="sourceSftpPort" class="form-control" placeholder="22" />
</div>
<div class="form-group">
<label for="sourceSftpUsername">${tf('source_sftp_username', 'SFTP username')}:</label>
<input type="text" id="sourceSftpUsername" class="form-control" placeholder="username" />
</div>
<div class="form-group">
<label for="sourceSftpPassword">${tf('source_sftp_password', 'SFTP password')}:</label>
<input type="password" id="sourceSftpPassword" class="form-control" autocomplete="new-password" />
</div>
</div>
<div class="form-group">
<label for="sourceSftpPrivateKey">${tf('source_sftp_private_key', 'SFTP private key')}:</label>
<textarea id="sourceSftpPrivateKey" class="form-control" rows="3" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
</div>
<div class="form-group">
<label for="sourceSftpPrivateKeyPassphrase">${tf('source_sftp_private_key_passphrase', 'SFTP key passphrase')}:</label>
<input type="password" id="sourceSftpPrivateKeyPassphrase" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceSftpRoot">${tf('source_sftp_root', 'SFTP root path')}:</label>
<input type="text" id="sourceSftpRoot" class="form-control" placeholder="/home/user/files" />
</div>
</div>
<div class="sources-type-block" data-type="ftp" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_ftp', 'Host and username are required. Passive mode is recommended. Root is optional; blank uses the login directory.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceFtpHost">${tf('source_ftp_host', 'FTP host')}:</label>
<input type="text" id="sourceFtpHost" class="form-control" placeholder="ftp.example.com" />
</div>
<div class="form-group">
<label for="sourceFtpPort">${tf('source_ftp_port', 'FTP port')}:</label>
<input type="number" id="sourceFtpPort" class="form-control" placeholder="21" />
</div>
<div class="form-group">
<label for="sourceFtpUsername">${tf('source_ftp_username', 'FTP username')}:</label>
<input type="text" id="sourceFtpUsername" class="form-control" placeholder="username" />
</div>
<div class="form-group">
<label for="sourceFtpPassword">${tf('source_ftp_password', 'FTP password')}:</label>
<input type="password" id="sourceFtpPassword" class="form-control" autocomplete="new-password" />
</div>
</div>
<div class="form-group" style="margin-top:6px;">
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourceFtpSsl" />
<label class="form-check-label" for="sourceFtpSsl">
${tf('source_ftp_ssl', 'Use FTPS (SSL)')}
</label>
</div>
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourceFtpPassive" />
<label class="form-check-label" for="sourceFtpPassive">
${tf('source_ftp_passive', 'Passive mode')}
</label>
</div>
</div>
<div class="form-group">
<label for="sourceFtpRoot">${tf('source_ftp_root', 'FTP root path')}:</label>
<input type="text" id="sourceFtpRoot" class="form-control" placeholder="/uploads" />
</div>
</div>
<div class="sources-type-block" data-type="webdav" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_webdav', 'Base URL and username are required. Root is optional; blank uses the server root. Do not embed credentials in the URL. Disable TLS verification only for self-signed certs.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceWebdavUrl">${tf('source_webdav_url', 'WebDAV base URL')}:</label>
<input type="text" id="sourceWebdavUrl" class="form-control" placeholder="https://example.com/remote.php/dav/files/user" />
</div>
<div class="form-group">
<label for="sourceWebdavUsername">${tf('source_webdav_username', 'WebDAV username')}:</label>
<input type="text" id="sourceWebdavUsername" class="form-control" placeholder="username" />
</div>
<div class="form-group">
<label for="sourceWebdavPassword">${tf('source_webdav_password', 'WebDAV password')}:</label>
<input type="password" id="sourceWebdavPassword" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceWebdavRoot">${tf('source_webdav_root', 'WebDAV root path')}:</label>
<input type="text" id="sourceWebdavRoot" class="form-control" placeholder="optional/path" />
</div>
</div>
<div class="form-group" style="margin-top:6px;">
<div class="form-check fr-toggle">
<input type="checkbox" class="form-check-input fr-toggle-input" id="sourceWebdavVerifyTls" />
<label class="form-check-label" for="sourceWebdavVerifyTls">
${tf('source_webdav_verify_tls', 'Verify TLS certificate')}
</label>
</div>
</div>
</div>
<div class="sources-type-block" data-type="smb" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_smb', 'Host, share, and username are required. Domain and root are optional. Leave SMB version on Auto unless your server requires a specific version.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceSmbHost">${tf('source_smb_host', 'SMB host')}:</label>
<input type="text" id="sourceSmbHost" class="form-control" placeholder="server.local" />
</div>
<div class="form-group">
<label for="sourceSmbShare">${tf('source_smb_share', 'SMB share')}:</label>
<input type="text" id="sourceSmbShare" class="form-control" placeholder="share" />
</div>
<div class="form-group">
<label for="sourceSmbUsername">${tf('source_smb_username', 'SMB username')}:</label>
<input type="text" id="sourceSmbUsername" class="form-control" placeholder="username" />
</div>
<div class="form-group">
<label for="sourceSmbPassword">${tf('source_smb_password', 'SMB password')}:</label>
<input type="password" id="sourceSmbPassword" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceSmbDomain">${tf('source_smb_domain', 'SMB domain (optional)')}:</label>
<input type="text" id="sourceSmbDomain" class="form-control" placeholder="WORKGROUP" />
</div>
<div class="form-group">
<label for="sourceSmbVersion">${tf('source_smb_version', 'SMB version')}:</label>
<select id="sourceSmbVersion" class="form-control">
<option value="">Auto</option>
<option value="SMB3">SMB3</option>
<option value="SMB2">SMB2</option>
<option value="SMB1">SMB1</option>
</select>
</div>
<div class="form-group">
<label for="sourceSmbRoot">${tf('source_smb_root', 'SMB root path')}:</label>
<input type="text" id="sourceSmbRoot" class="form-control" placeholder="optional/path" />
</div>
</div>
</div>
<div class="sources-type-block" data-type="gdrive" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_gdrive', 'Create an OAuth client in Google Cloud. Get a refresh token with scope https://www.googleapis.com/auth/drive. RootId: drive.google.com/drive/folders/ID or blank for root. DriveId: set for shared drives. Native Docs/Sheets exports not supported yet.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceGDriveClientId">${tf('source_gdrive_client_id', 'Google client ID')}:</label>
<input type="text" id="sourceGDriveClientId" class="form-control" placeholder="...apps.googleusercontent.com" />
</div>
<div class="form-group">
<label for="sourceGDriveClientSecret">${tf('source_gdrive_client_secret', 'Google client secret')}:</label>
<input type="password" id="sourceGDriveClientSecret" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceGDriveRefreshToken">${tf('source_gdrive_refresh_token', 'Google refresh token')}:</label>
<input type="password" id="sourceGDriveRefreshToken" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceGDriveRootId">${tf('source_gdrive_root_id', 'Root folder ID')}:</label>
<input type="text" id="sourceGDriveRootId" class="form-control" placeholder="root" />
</div>
<div class="form-group">
<label for="sourceGDriveDriveId">${tf('source_gdrive_drive_id', 'Shared drive ID (optional)')}:</label>
<input type="text" id="sourceGDriveDriveId" class="form-control" placeholder="" />
</div>
</div>
<div class="text-muted small" style="margin-top:6px;">
${tf('source_gdrive_trash_note', 'Trash is not supported on Google Drive sources; deletes are permanent.')}
</div>
</div>
<div class="sources-type-block" data-type="onedrive" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_onedrive', 'Create a Microsoft Entra app with delegated Files.ReadWrite.All + offline_access. Tenant: use common/organizations/consumers or a tenant ID. For SharePoint/Business, supply siteId or driveId. Root path is optional and scopes the source to a subfolder.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceOneDriveClientId">${tf('source_onedrive_client_id', 'OneDrive client ID')}:</label>
<input type="text" id="sourceOneDriveClientId" class="form-control" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div class="form-group">
<label for="sourceOneDriveClientSecret">${tf('source_onedrive_client_secret', 'OneDrive client secret')}:</label>
<input type="password" id="sourceOneDriveClientSecret" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceOneDriveRefreshToken">${tf('source_onedrive_refresh_token', 'OneDrive refresh token')}:</label>
<input type="password" id="sourceOneDriveRefreshToken" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceOneDriveTenant">${tf('source_onedrive_tenant', 'Tenant (optional)')}:</label>
<input type="text" id="sourceOneDriveTenant" class="form-control" placeholder="common" />
</div>
<div class="form-group">
<label for="sourceOneDriveDriveId">${tf('source_onedrive_drive_id', 'Drive ID (optional)')}:</label>
<input type="text" id="sourceOneDriveDriveId" class="form-control" placeholder="" />
</div>
<div class="form-group">
<label for="sourceOneDriveSiteId">${tf('source_onedrive_site_id', 'Site ID (optional)')}:</label>
<input type="text" id="sourceOneDriveSiteId" class="form-control" placeholder="" />
</div>
<div class="form-group">
<label for="sourceOneDriveRootPath">${tf('source_onedrive_root_path', 'Root path (optional)')}:</label>
<input type="text" id="sourceOneDriveRootPath" class="form-control" placeholder="optional/path" />
</div>
</div>
</div>
<div class="sources-type-block" data-type="dropbox" hidden>
<div class="sources-hint-row">
<button type="button" class="sources-hint-btn" aria-label="${tf('source_hint_button', 'Show setup hints')}" data-tooltip="${tf('source_hint_dropbox', 'Create a Dropbox app with files.content.write, files.content.read, and files.metadata.read. Generate a refresh token. TeamMemberId and RootNamespaceId are optional for Dropbox Business team spaces. Root path scopes the source to a subfolder.')}">!</button>
</div>
<div class="sources-form-grid">
<div class="form-group">
<label for="sourceDropboxAppKey">${tf('source_dropbox_app_key', 'Dropbox app key')}:</label>
<input type="text" id="sourceDropboxAppKey" class="form-control" placeholder="app-key" />
</div>
<div class="form-group">
<label for="sourceDropboxAppSecret">${tf('source_dropbox_app_secret', 'Dropbox app secret')}:</label>
<input type="password" id="sourceDropboxAppSecret" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceDropboxRefreshToken">${tf('source_dropbox_refresh_token', 'Dropbox refresh token')}:</label>
<input type="password" id="sourceDropboxRefreshToken" class="form-control" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="sourceDropboxRootPath">${tf('source_dropbox_root_path', 'Root path (optional)')}:</label>
<input type="text" id="sourceDropboxRootPath" class="form-control" placeholder="optional/path" />
</div>
<div class="form-group">
<label for="sourceDropboxTeamMemberId">${tf('source_dropbox_team_member_id', 'Team member ID (optional)')}:</label>
<input type="text" id="sourceDropboxTeamMemberId" class="form-control" placeholder="" />
</div>
<div class="form-group">
<label for="sourceDropboxRootNamespaceId">${tf('source_dropbox_root_namespace_id', 'Root namespace ID (optional)')}:</label>
<input type="text" id="sourceDropboxRootNamespaceId" class="form-control" placeholder="" />
</div>
</div>
</div>
<div class="sources-form-actions">
<button type="button" id="sourceSaveBtn" class="btn btn-sm btn-primary">
${tf('source_save', 'Save Source')}
</button>
<button type="button" id="sourceResetBtn" class="btn btn-sm btn-secondary">
${t('cancel')}
</button>
</div>
<div class="text-muted small" style="margin-top:6px;">
${tf('source_secret_note', 'Secrets are never shown after saving. Leave blank to keep existing values.')}
</div>
</div>
</div>
`;
const statusEl = container.querySelector('#sourcesStatus');
const listEl = container.querySelector('#sourcesList');
const enabledToggle = container.querySelector('#sourcesEnabledToggle');
const addBtn = container.querySelector('#sourceAddBtn');
const refreshBtn = container.querySelector('#sourceRefreshBtn');
const formTitleEl = container.querySelector('#sourceFormTitle');
const saveBtn = container.querySelector('#sourceSaveBtn');
const resetBtn = container.querySelector('#sourceResetBtn');
const sourceIdEl = container.querySelector('#sourceId');
const sourceNameEl = container.querySelector('#sourceName');
const sourceTypeEl = container.querySelector('#sourceType');
const sourceEnabledEl = container.querySelector('#sourceEnabled');
const sourceReadOnlyEl = container.querySelector('#sourceReadOnly');
const localPathEl = container.querySelector('#sourceLocalPath');
const s3BucketEl = container.querySelector('#sourceS3Bucket');
const s3RegionEl = container.querySelector('#sourceS3Region');
const s3EndpointEl = container.querySelector('#sourceS3Endpoint');
const s3PrefixEl = container.querySelector('#sourceS3Prefix');
const s3AccessKeyEl = container.querySelector('#sourceS3AccessKey');
const s3SecretKeyEl = container.querySelector('#sourceS3SecretKey');
const s3SessionTokenEl = container.querySelector('#sourceS3SessionToken');
const s3PathStyleEl = container.querySelector('#sourceS3PathStyle');
const sftpHostEl = container.querySelector('#sourceSftpHost');
const sftpPortEl = container.querySelector('#sourceSftpPort');
const sftpUsernameEl = container.querySelector('#sourceSftpUsername');
const sftpPasswordEl = container.querySelector('#sourceSftpPassword');
const sftpPrivateKeyEl = container.querySelector('#sourceSftpPrivateKey');
const sftpPrivateKeyPassEl = container.querySelector('#sourceSftpPrivateKeyPassphrase');
const sftpRootEl = container.querySelector('#sourceSftpRoot');
const ftpHostEl = container.querySelector('#sourceFtpHost');
const ftpPortEl = container.querySelector('#sourceFtpPort');
const ftpUsernameEl = container.querySelector('#sourceFtpUsername');
const ftpPasswordEl = container.querySelector('#sourceFtpPassword');
const ftpSslEl = container.querySelector('#sourceFtpSsl');
const ftpPassiveEl = container.querySelector('#sourceFtpPassive');
const ftpRootEl = container.querySelector('#sourceFtpRoot');
const webdavUrlEl = container.querySelector('#sourceWebdavUrl');
const webdavUsernameEl = container.querySelector('#sourceWebdavUsername');
const webdavPasswordEl = container.querySelector('#sourceWebdavPassword');
const webdavRootEl = container.querySelector('#sourceWebdavRoot');
const webdavVerifyTlsEl = container.querySelector('#sourceWebdavVerifyTls');
const smbHostEl = container.querySelector('#sourceSmbHost');
const smbShareEl = container.querySelector('#sourceSmbShare');
const smbUsernameEl = container.querySelector('#sourceSmbUsername');
const smbPasswordEl = container.querySelector('#sourceSmbPassword');
const smbDomainEl = container.querySelector('#sourceSmbDomain');
const smbVersionEl = container.querySelector('#sourceSmbVersion');
const smbRootEl = container.querySelector('#sourceSmbRoot');
const gdriveClientIdEl = container.querySelector('#sourceGDriveClientId');
const gdriveClientSecretEl = container.querySelector('#sourceGDriveClientSecret');
const gdriveRefreshTokenEl = container.querySelector('#sourceGDriveRefreshToken');
const gdriveRootIdEl = container.querySelector('#sourceGDriveRootId');
const gdriveDriveIdEl = container.querySelector('#sourceGDriveDriveId');
const onedriveClientIdEl = container.querySelector('#sourceOneDriveClientId');
const onedriveClientSecretEl = container.querySelector('#sourceOneDriveClientSecret');
const onedriveRefreshTokenEl = container.querySelector('#sourceOneDriveRefreshToken');
const onedriveTenantEl = container.querySelector('#sourceOneDriveTenant');
const onedriveDriveIdEl = container.querySelector('#sourceOneDriveDriveId');
const onedriveSiteIdEl = container.querySelector('#sourceOneDriveSiteId');
const onedriveRootPathEl = container.querySelector('#sourceOneDriveRootPath');
const dropboxAppKeyEl = container.querySelector('#sourceDropboxAppKey');
const dropboxAppSecretEl = container.querySelector('#sourceDropboxAppSecret');
const dropboxRefreshTokenEl = container.querySelector('#sourceDropboxRefreshToken');
const dropboxRootPathEl = container.querySelector('#sourceDropboxRootPath');
const dropboxTeamMemberIdEl = container.querySelector('#sourceDropboxTeamMemberId');
const dropboxRootNamespaceIdEl = container.querySelector('#sourceDropboxRootNamespaceId');
const typeBlocks = Array.from(container.querySelectorAll('.sources-type-block'));
let editingId = '';
let state = {
enabled: !!sourcesEnabled,
sources: Array.isArray(sourcesCfg.sources) ? sourcesCfg.sources : [],
activeId: sourcesCfg.activeId || sourcesCfg.active || sourcesCfg.selected || sourcesCfg.current || '',
testStatus: {}
};
const esc = (val) => escapeHTML(val == null ? '' : String(val));
const getCsrf = () =>
document.querySelector('meta[name="csrf-token"]')?.content ||
window.csrfToken ||
'';
const setStatus = (msg, tone = 'muted') => {
if (!statusEl) return;
statusEl.className = `text-${tone} small`;
statusEl.textContent = msg || '';
};
const setSavingState = (saving, message) => {
if (saveBtn) {
saveBtn.disabled = !!saving;
}
if (saving && message) {
setStatus(message);
}
};
const refreshSourceSelectorSafe = async (origin) => {
try {
if (typeof window.__frRefreshSourceSelector === 'function') {
await window.__frRefreshSourceSelector({ origin });
}
} catch (e) { /* ignore */ }
};
const normalizeTestState = (value) =>
(value === 'testing' || value === 'ok' || value === 'error') ? value : 'idle';
const truncateTestMessage = (msg, max = 80) => {
const clean = String(msg || '').replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return clean.slice(0, max) + '...';
};
const testStatusLabel = (state, message) => {
if (state === 'testing') return tf('source_test_running', 'Testing...');
if (state === 'ok') return tf('source_test_ok', 'Connected');
if (state === 'error') {
const base = tf('source_test_failed', 'Failed');
const detail = message ? truncateTestMessage(message, 60) : '';
return detail ? `${base}: ${detail}` : base;
}
return tf('source_test_idle', 'Not tested');
};
const getTestStatus = (id) => {
const entry = (state.testStatus && state.testStatus[id]) ? state.testStatus[id] : {};
return {
state: normalizeTestState(entry.state),
message: entry.message ? String(entry.message) : ''
};
};
const setTestStatus = (id, next) => {
if (!id) return;
const current = (state.testStatus && state.testStatus[id]) ? state.testStatus[id] : {};
state.testStatus[id] = { ...current, ...next };
renderList();
};
const pruneTestStatus = () => {
if (!state.testStatus) return;
const ids = new Set((state.sources || []).map(src => String(src.id || '')));
Object.keys(state.testStatus).forEach(id => {
if (!ids.has(id)) {
delete state.testStatus[id];
}
});
};
const setType = (type) => {
const t = (type || 'local').toLowerCase();
sourceTypeEl.value = t;
typeBlocks.forEach(block => {
block.hidden = block.getAttribute('data-type') !== t;
});
};
const resetSecrets = () => {
[
s3AccessKeyEl,
s3SecretKeyEl,
s3SessionTokenEl,
sftpPasswordEl,
sftpPrivateKeyEl,
sftpPrivateKeyPassEl,
ftpPasswordEl,
webdavPasswordEl,
smbPasswordEl,
gdriveClientSecretEl,
gdriveRefreshTokenEl,
onedriveClientSecretEl,
onedriveRefreshTokenEl,
dropboxAppSecretEl,
dropboxRefreshTokenEl,
].forEach(el => {
if (!el) return;
el.value = '';
el.placeholder = '';
});
};
const resetForm = () => {
editingId = '';
formTitleEl.textContent = tf('source_add', 'Add Source');
if (sourceIdEl) {
sourceIdEl.disabled = false;
sourceIdEl.value = '';
}
if (sourceNameEl) sourceNameEl.value = '';
if (sourceEnabledEl) sourceEnabledEl.checked = true;
if (sourceReadOnlyEl) sourceReadOnlyEl.checked = false;
if (localPathEl) localPathEl.value = '';
if (s3BucketEl) s3BucketEl.value = '';
if (s3RegionEl) s3RegionEl.value = '';
if (s3EndpointEl) s3EndpointEl.value = '';
if (s3PrefixEl) s3PrefixEl.value = '';
if (s3PathStyleEl) s3PathStyleEl.checked = false;
if (sftpHostEl) sftpHostEl.value = '';
if (sftpPortEl) sftpPortEl.value = '';
if (sftpUsernameEl) sftpUsernameEl.value = '';
if (sftpRootEl) sftpRootEl.value = '';
if (ftpHostEl) ftpHostEl.value = '';
if (ftpPortEl) ftpPortEl.value = '';
if (ftpUsernameEl) ftpUsernameEl.value = '';
if (ftpSslEl) ftpSslEl.checked = false;
if (ftpPassiveEl) ftpPassiveEl.checked = true;
if (ftpRootEl) ftpRootEl.value = '';
if (webdavUrlEl) webdavUrlEl.value = '';
if (webdavUsernameEl) webdavUsernameEl.value = '';
if (webdavRootEl) webdavRootEl.value = '';
if (webdavVerifyTlsEl) webdavVerifyTlsEl.checked = true;
if (smbHostEl) smbHostEl.value = '';
if (smbShareEl) smbShareEl.value = '';
if (smbUsernameEl) smbUsernameEl.value = '';
if (smbDomainEl) smbDomainEl.value = '';
if (smbVersionEl) smbVersionEl.value = '';
if (smbRootEl) smbRootEl.value = '';
if (gdriveClientIdEl) gdriveClientIdEl.value = '';
if (gdriveRootIdEl) gdriveRootIdEl.value = '';
if (gdriveDriveIdEl) gdriveDriveIdEl.value = '';
if (onedriveClientIdEl) onedriveClientIdEl.value = '';
if (onedriveTenantEl) onedriveTenantEl.value = '';
if (onedriveDriveIdEl) onedriveDriveIdEl.value = '';
if (onedriveSiteIdEl) onedriveSiteIdEl.value = '';
if (onedriveRootPathEl) onedriveRootPathEl.value = '';
if (dropboxAppKeyEl) dropboxAppKeyEl.value = '';
if (dropboxRootPathEl) dropboxRootPathEl.value = '';
if (dropboxTeamMemberIdEl) dropboxTeamMemberIdEl.value = '';
if (dropboxRootNamespaceIdEl) dropboxRootNamespaceIdEl.value = '';
resetSecrets();
setType('local');
if (listEl) renderList();
};
const applySecretPlaceholders = (src) => {
const cfg = (src && src.config) ? src.config : {};
const setSaved = (el, has) => {
if (!el) return;
el.value = '';
el.placeholder = has ? '•••••• (saved)' : '';
};
setSaved(s3AccessKeyEl, !!cfg.hasAccessKey);
setSaved(s3SecretKeyEl, !!cfg.hasSecretKey);
setSaved(s3SessionTokenEl, !!cfg.hasSessionToken);
setSaved(sftpPasswordEl, !!cfg.hasPassword);
setSaved(sftpPrivateKeyEl, !!cfg.hasPrivateKey);
setSaved(sftpPrivateKeyPassEl, !!cfg.hasPrivateKeyPassphrase);
setSaved(ftpPasswordEl, !!cfg.hasPassword);
setSaved(webdavPasswordEl, !!cfg.hasPassword);
setSaved(smbPasswordEl, !!cfg.hasPassword);
setSaved(gdriveClientSecretEl, !!cfg.hasClientSecret);
setSaved(gdriveRefreshTokenEl, !!cfg.hasRefreshToken);
setSaved(onedriveClientSecretEl, !!cfg.hasClientSecret);
setSaved(onedriveRefreshTokenEl, !!cfg.hasRefreshToken);
setSaved(dropboxAppSecretEl, !!cfg.hasAppSecret);
setSaved(dropboxRefreshTokenEl, !!cfg.hasRefreshToken);
};
const fillForm = (src) => {
if (!src || typeof src !== 'object') return;
editingId = String(src.id || '');
formTitleEl.textContent = tf('source_edit', 'Edit Source');
if (sourceIdEl) {
sourceIdEl.value = src.id || '';
sourceIdEl.disabled = true;
}
if (sourceNameEl) sourceNameEl.value = src.name || '';
if (sourceEnabledEl) sourceEnabledEl.checked = src.enabled !== false;
if (sourceReadOnlyEl) sourceReadOnlyEl.checked = !!src.readOnly;
const type = (src.type || 'local').toLowerCase();
setType(type);
const cfg = src.config || {};
if (localPathEl) localPathEl.value = cfg.path || cfg.root || '';
if (s3BucketEl) s3BucketEl.value = cfg.bucket || '';
if (s3RegionEl) s3RegionEl.value = cfg.region || '';
if (s3EndpointEl) s3EndpointEl.value = cfg.endpoint || '';
if (s3PrefixEl) s3PrefixEl.value = cfg.prefix || '';
if (s3PathStyleEl) s3PathStyleEl.checked = !!cfg.pathStyle;
if (sftpHostEl) sftpHostEl.value = cfg.host || '';
if (sftpPortEl) sftpPortEl.value = cfg.port || '';
if (sftpUsernameEl) sftpUsernameEl.value = cfg.username || '';
if (sftpRootEl) sftpRootEl.value = cfg.root || cfg.path || '';
if (ftpHostEl) ftpHostEl.value = cfg.host || '';
if (ftpPortEl) ftpPortEl.value = cfg.port || '';
if (ftpUsernameEl) ftpUsernameEl.value = cfg.username || '';
if (ftpSslEl) ftpSslEl.checked = !!cfg.ssl;
if (ftpPassiveEl) {
const passive = (typeof cfg.passive === 'undefined') ? true : !!cfg.passive;
ftpPassiveEl.checked = passive;
}
if (ftpRootEl) ftpRootEl.value = cfg.root || cfg.path || '';
if (webdavUrlEl) webdavUrlEl.value = cfg.baseUrl || cfg.url || '';
if (webdavUsernameEl) webdavUsernameEl.value = cfg.username || '';
if (webdavRootEl) webdavRootEl.value = cfg.root || cfg.path || '';
if (webdavVerifyTlsEl) {
webdavVerifyTlsEl.checked = (typeof cfg.verifyTls === 'undefined') ? true : !!cfg.verifyTls;
}
if (smbHostEl) smbHostEl.value = cfg.host || '';
if (smbShareEl) smbShareEl.value = cfg.share || '';
if (smbUsernameEl) smbUsernameEl.value = cfg.username || '';
if (smbDomainEl) smbDomainEl.value = cfg.domain || '';
if (smbVersionEl) smbVersionEl.value = cfg.version || '';
if (smbRootEl) smbRootEl.value = cfg.root || cfg.path || '';
if (gdriveClientIdEl) gdriveClientIdEl.value = cfg.clientId || '';
if (gdriveRootIdEl) gdriveRootIdEl.value = cfg.rootId || '';
if (gdriveDriveIdEl) gdriveDriveIdEl.value = cfg.driveId || '';
if (onedriveClientIdEl) onedriveClientIdEl.value = cfg.clientId || '';
if (onedriveTenantEl) onedriveTenantEl.value = cfg.tenant || '';
if (onedriveDriveIdEl) onedriveDriveIdEl.value = cfg.driveId || '';
if (onedriveSiteIdEl) onedriveSiteIdEl.value = cfg.siteId || '';
if (onedriveRootPathEl) onedriveRootPathEl.value = cfg.rootPath || '';
if (dropboxAppKeyEl) dropboxAppKeyEl.value = cfg.appKey || '';
if (dropboxRootPathEl) dropboxRootPathEl.value = cfg.rootPath || '';
if (dropboxTeamMemberIdEl) dropboxTeamMemberIdEl.value = cfg.teamMemberId || '';
if (dropboxRootNamespaceIdEl) dropboxRootNamespaceIdEl.value = cfg.rootNamespaceId || '';
applySecretPlaceholders(src);
if (listEl) renderList();
};
const renderList = () => {
const rows = Array.isArray(state.sources) ? state.sources : [];
if (!rows.length) {
listEl.innerHTML = `<div class="text-muted">${tf('source_list_empty', 'No sources configured yet.')}</div>`;
return;
}
const selectedId = editingId ? String(editingId) : '';
const activeRowId = (selectedId && rows.some(src => String(src.id || '') === selectedId))
? selectedId
: '';
const header = `
<div class="sources-row sources-header">
<div>${tf('source_name', 'Source Name')}</div>
<div>${tf('source_type', 'Type')}</div>
<div>${tf('source_id', 'Source ID')}</div>
<div class="sources-header-actions">${tf('actions', 'Actions')}</div>
</div>
`;
const body = rows.map((src, idx) => {
const id = String(src.id || '');
const name = String(src.name || id || '');
const type = String(src.type || '');
const enabledText = (src.enabled === false) ? tf('disabled', 'Disabled') : tf('enabled', 'Enabled');
const flags = [
enabledText,
(src.readOnly ? t('read_only') : '')
].filter(Boolean);
const badges = flags.map(flag => `<span class="sources-badge">${esc(flag)}</span>`).join('');
const badgeWrap = badges ? `<span class="sources-badges">${badges}</span>` : '';
const testState = getTestStatus(id);
const statusText = testStatusLabel(testState.state, testState.message);
const statusClass = `sources-test-status status-${testState.state}`;
const statusTitle = testState.message ? ` title="${esc(testState.message)}"` : '';
const testDisabled = (testState.state === 'testing') ? 'disabled' : '';
const rowClass = [
'sources-row',
'sources-row-data',
(idx % 2 === 1) ? 'is-alt' : '',
(activeRowId && id === activeRowId) ? 'is-active' : ''
].filter(Boolean).join(' ');
const readOnlyLabel = tf('read_only', 'Read-only');
const lockIcon = src.readOnly
? `<span class="material-icons sources-lock-icon" title="${esc(readOnlyLabel)}" aria-hidden="true">lock</span>`
: '';
const testLabel = tf('source_test', 'Test');
return `
<div class="${rowClass}" data-id="${esc(id)}">
<div class="sources-cell sources-name"><span class="sources-name-text">${esc(name)}</span>${lockIcon}${badgeWrap}</div>
<div class="sources-cell">${esc(type)}</div>
<div class="sources-cell"><code>${esc(id)}</code></div>
<div class="sources-actions">
<span class="${statusClass}"${statusTitle}>${esc(statusText)}</span>
<button type="button" class="btn btn-sm btn-secondary sources-icon-btn" data-action="test" data-id="${esc(id)}" ${testDisabled} title="${esc(testLabel)}" aria-label="${esc(testLabel)}">
<span class="material-icons" aria-hidden="true">science</span>
</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="edit" data-id="${esc(id)}">
${tf('source_edit', 'Edit')}
</button>
<button type="button" class="btn btn-sm btn-danger" data-action="delete" data-id="${esc(id)}">
${tf('source_delete', 'Delete')}
</button>
</div>
</div>
`;
}).join('');
listEl.innerHTML = `<div class="sources-grid">${header}${body}</div>`;
};
const loadSources = async () => {
if (!isPro || !proSourcesApiOk || window.__FR_IS_PRO === false) {
return;
}
setStatus(tf('loading', 'Loading...'));
listEl.innerHTML = `<div class="text-muted">${tf('loading', 'Loading...')}</div>`;
let data = null;
try {
const res = await fetch(withBase('/api/pro/sources/list.php'), {
method: 'GET',
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
data = await safeJson(res);
} catch (e) {
console.warn('Sources list failed', e);
setStatus(e?.message || tf('error', 'Error'), 'danger');
}
if (data && data.ok === true) {
state.enabled = !!data.enabled;
state.sources = Array.isArray(data.sources) ? data.sources : [];
state.activeId = data.activeId || '';
setStatus('');
} else if (!state.sources.length) {
state.sources = Array.isArray(sourcesCfg.sources) ? sourcesCfg.sources : [];
}
if (enabledToggle) enabledToggle.checked = !!state.enabled;
pruneTestStatus();
renderList();
};
const runSourceTest = async (src) => {
const id = String(src.id || '');
if (!id) return null;
const current = getTestStatus(id);
if (current.state === 'testing') {
return null;
}
setTestStatus(id, { state: 'testing', message: '' });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 25000);
try {
const res = await fetch(withBase('/api/pro/sources/test.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
'Accept': 'application/json',
},
body: JSON.stringify({ id }),
signal: controller.signal,
});
const data = await safeJson(res);
const msg = data && data.message ? String(data.message) : '';
setTestStatus(id, { state: 'ok', message: msg });
return true;
} catch (err) {
const timedOut = err && err.name === 'AbortError';
const msg = timedOut
? tf('source_test_timeout', 'Test timed out.')
: (err?.message || tf('source_test_error', 'Test failed'));
setTestStatus(id, { state: 'error', message: msg });
showToast(msg, 5000);
return false;
} finally {
clearTimeout(timer);
}
};
if (enabledToggle) {
enabledToggle.checked = !!state.enabled;
enabledToggle.addEventListener('change', async () => {
const next = !!enabledToggle.checked;
try {
const res = await fetch(withBase('/api/pro/sources/save.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
'Accept': 'application/json',
},
body: JSON.stringify({ enabled: next }),
});
const data = await safeJson(res);
if (!data || data.ok !== true) {
throw new Error(data?.error || 'Failed to save sources setting.');
}
state.enabled = next;
setStatus(tf('settings_updated_successfully', 'Settings updated successfully.'));
await refreshSourceSelectorSafe('admin-sources-toggle');
} catch (e) {
console.warn('Sources enabled save failed', e);
enabledToggle.checked = !next;
setStatus(e?.message || tf('error_updating_settings', 'Error updating settings'), 'danger');
}
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', loadSources);
}
if (addBtn) {
addBtn.addEventListener('click', () => {
resetForm();
});
}
if (resetBtn) {
resetBtn.addEventListener('click', () => resetForm());
}
if (sourceTypeEl) {
sourceTypeEl.addEventListener('change', () => setType(sourceTypeEl.value));
}
if (listEl) {
listEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = btn.getAttribute('data-id') || '';
const action = btn.getAttribute('data-action') || '';
const src = (state.sources || []).find(s => String(s.id || '') === id);
if (!src) return;
if (action === 'edit') {
fillForm(src);
return;
}
if (action === 'test') {
runSourceTest(src);
return;
}
if (action === 'delete') {
const ok = window.confirm(
`Delete source "${src.name || src.id}"? This does not remove any stored files.`
);
if (!ok) return;
(async () => {
try {
const res = await fetch(withBase('/api/pro/sources/delete.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
'Accept': 'application/json',
},
body: JSON.stringify({ id: src.id }),
});
const data = await safeJson(res);
if (!data || data.ok !== true) {
throw new Error(data?.error || 'Failed to delete source.');
}
resetForm();
await loadSources();
await refreshSourceSelectorSafe('admin-sources-delete');
} catch (err) {
showToast(err?.message || tf('error', 'Error'), 'error');
}
})();
}
});
}
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const id = (sourceIdEl?.value || '').trim();
const name = (sourceNameEl?.value || '').trim();
const type = (sourceTypeEl?.value || '').trim().toLowerCase();
const enabled = !!sourceEnabledEl?.checked;
const readOnly = !!sourceReadOnlyEl?.checked;
if (!id || !name || !type) {
showToast(t('admin_source_required_fields'), 'error');
return;
}
const config = {};
if (type === 'local') {
const path = (localPathEl?.value || '').trim();
if (path) {
config.path = path;
}
} else if (type === 's3') {
const bucket = (s3BucketEl?.value || '').trim();
if (!bucket) {
showToast(t('admin_source_s3_bucket_required'), 'error');
return;
}
config.bucket = bucket;
const region = (s3RegionEl?.value || '').trim();
const endpoint = (s3EndpointEl?.value || '').trim();
const prefix = (s3PrefixEl?.value || '').trim();
const accessKey = (s3AccessKeyEl?.value || '').trim();
const secretKey = (s3SecretKeyEl?.value || '').trim();
const sessionToken = (s3SessionTokenEl?.value || '').trim();
if (region) config.region = region;
if (endpoint) config.endpoint = endpoint;
if (prefix) config.prefix = prefix;
if (accessKey) config.accessKey = accessKey;
if (secretKey) config.secretKey = secretKey;
if (sessionToken) config.sessionToken = sessionToken;
config.pathStyle = !!s3PathStyleEl?.checked;
} else if (type === 'sftp') {
const host = (sftpHostEl?.value || '').trim();
const username = (sftpUsernameEl?.value || '').trim();
const password = (sftpPasswordEl?.value || '').trim();
const privateKey = (sftpPrivateKeyEl?.value || '').trim();
const privateKeyPassphrase = (sftpPrivateKeyPassEl?.value || '').trim();
if (!host || !username) {
showToast(t('admin_source_sftp_host_required'), 'error');
return;
}
if (!password && !privateKey && !editingId) {
showToast(t('admin_source_sftp_auth_required'), 'error');
return;
}
config.host = host;
config.username = username;
const port = parseInt(sftpPortEl?.value || '', 10);
if (Number.isFinite(port) && port > 0) {
config.port = port;
}
const root = (sftpRootEl?.value || '').trim();
if (root) config.root = root;
if (password) config.password = password;
if (privateKey) config.privateKey = privateKey;
if (privateKeyPassphrase) config.privateKeyPassphrase = privateKeyPassphrase;
} else if (type === 'ftp') {
const host = (ftpHostEl?.value || '').trim();
const username = (ftpUsernameEl?.value || '').trim();
const password = (ftpPasswordEl?.value || '').trim();
if (!host || !username) {
showToast(t('admin_source_ftp_host_required'), 'error');
return;
}
config.host = host;
config.username = username;
const port = parseInt(ftpPortEl?.value || '', 10);
if (Number.isFinite(port) && port > 0) {
config.port = port;
}
const root = (ftpRootEl?.value || '').trim();
if (root) config.root = root;
if (password) config.password = password;
config.ssl = !!ftpSslEl?.checked;
config.passive = (ftpPassiveEl?.checked !== false);
} else if (type === 'webdav') {
const baseUrl = (webdavUrlEl?.value || '').trim();
const username = (webdavUsernameEl?.value || '').trim();
const password = (webdavPasswordEl?.value || '').trim();
const root = (webdavRootEl?.value || '').trim();
const verifyTls = (webdavVerifyTlsEl?.checked !== false);
if (!baseUrl || !username) {
showToast(t('admin_source_webdav_base_required'), 'error');
return;
}
if (!password && !editingId) {
showToast(t('admin_source_webdav_password_required'), 'error');
return;
}
config.baseUrl = baseUrl;
config.username = username;
if (password) config.password = password;
if (root) config.root = root;
config.verifyTls = !!verifyTls;
} else if (type === 'smb') {
const host = (smbHostEl?.value || '').trim();
const share = (smbShareEl?.value || '').trim();
const username = (smbUsernameEl?.value || '').trim();
const password = (smbPasswordEl?.value || '').trim();
const domain = (smbDomainEl?.value || '').trim();
const version = (smbVersionEl?.value || '').trim();
const root = (smbRootEl?.value || '').trim();
if (!host || !share || !username) {
showToast(t('admin_source_smb_host_required'), 'error');
return;
}
if (!password && !editingId) {
showToast(t('admin_source_smb_password_required'), 'error');
return;
}
config.host = host;
config.share = share;
config.username = username;
if (password) config.password = password;
if (domain) config.domain = domain;
if (version) config.version = version;
if (root) config.root = root;
} else if (type === 'gdrive') {
const clientId = (gdriveClientIdEl?.value || '').trim();
const clientSecret = (gdriveClientSecretEl?.value || '').trim();
const refreshToken = (gdriveRefreshTokenEl?.value || '').trim();
const rootId = (gdriveRootIdEl?.value || '').trim();
const driveId = (gdriveDriveIdEl?.value || '').trim();
if (!clientId) {
showToast(t('admin_source_gdrive_client_required'), 'error');
return;
}
if ((!clientSecret || !refreshToken) && !editingId) {
showToast(t('admin_source_gdrive_secret_required'), 'error');
return;
}
config.clientId = clientId;
if (clientSecret) config.clientSecret = clientSecret;
if (refreshToken) config.refreshToken = refreshToken;
if (rootId) config.rootId = rootId;
if (driveId) config.driveId = driveId;
} else if (type === 'onedrive') {
const clientId = (onedriveClientIdEl?.value || '').trim();
const clientSecret = (onedriveClientSecretEl?.value || '').trim();
const refreshToken = (onedriveRefreshTokenEl?.value || '').trim();
const tenant = (onedriveTenantEl?.value || '').trim();
const driveId = (onedriveDriveIdEl?.value || '').trim();
const siteId = (onedriveSiteIdEl?.value || '').trim();
const rootPath = (onedriveRootPathEl?.value || '').trim();
if (!clientId) {
showToast(tf('admin_source_onedrive_client_required', 'OneDrive client ID is required.'), 'error');
return;
}
if ((!clientSecret || !refreshToken) && !editingId) {
showToast(tf('admin_source_onedrive_secret_required', 'OneDrive client secret and refresh token are required.'), 'error');
return;
}
config.clientId = clientId;
if (clientSecret) config.clientSecret = clientSecret;
if (refreshToken) config.refreshToken = refreshToken;
if (tenant) config.tenant = tenant;
if (driveId) config.driveId = driveId;
if (siteId) config.siteId = siteId;
if (rootPath) config.rootPath = rootPath;
} else if (type === 'dropbox') {
const appKey = (dropboxAppKeyEl?.value || '').trim();
const appSecret = (dropboxAppSecretEl?.value || '').trim();
const refreshToken = (dropboxRefreshTokenEl?.value || '').trim();
const rootPath = (dropboxRootPathEl?.value || '').trim();
const teamMemberId = (dropboxTeamMemberIdEl?.value || '').trim();
const rootNamespaceId = (dropboxRootNamespaceIdEl?.value || '').trim();
if (!appKey) {
showToast(tf('admin_source_dropbox_app_required', 'Dropbox app key is required.'), 'error');
return;
}
if ((!appSecret || !refreshToken) && !editingId) {
showToast(tf('admin_source_dropbox_secret_required', 'Dropbox app secret and refresh token are required.'), 'error');
return;
}
config.appKey = appKey;
if (appSecret) config.appSecret = appSecret;
if (refreshToken) config.refreshToken = refreshToken;
if (rootPath) config.rootPath = rootPath;
if (teamMemberId) config.teamMemberId = teamMemberId;
if (rootNamespaceId) config.rootNamespaceId = rootNamespaceId;
}
const payload = {
source: { id, name, type, enabled, readOnly, config }
};
const existingIdx = (state.sources || []).findIndex(s => String(s.id || '') === id);
const isNewSource = existingIdx === -1;
const optimisticEnabled = isNewSource
? false
: ((state.sources[existingIdx]?.enabled) !== false);
const optimisticSource = { id, name, type, enabled: optimisticEnabled, readOnly };
if (isNewSource) {
state.sources = [...(state.sources || []), optimisticSource];
} else if (existingIdx >= 0) {
state.sources[existingIdx] = { ...state.sources[existingIdx], ...optimisticSource };
}
if (isNewSource || existingIdx >= 0) {
setTestStatus(id, { state: 'testing', message: tf('saving_source_short', 'Saving...') });
}
setSavingState(true, tf('saving_source', 'Saving source... this may take a moment.'));
showToast(tf('saving_source_toast', 'Saving source...'), 2000);
try {
const res = await fetch(withBase('/api/pro/sources/save.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await safeJson(res);
if (!data || data.ok !== true) {
throw new Error(data?.error || 'Failed to save source.');
}
await loadSources();
if (data?.autoTested) {
if (data.autoTestOk) {
setTestStatus(id, { state: 'ok', message: '' });
} else {
const errMsg = data.autoTestError || tf('source_test_error', 'Test failed');
setTestStatus(id, { state: 'error', message: errMsg });
const disabledNote = data.autoDisabled ? t('admin_source_test_left_disabled_note') : '';
showToast(t('admin_source_test_failed_detail', { error: errMsg, note: disabledNote }), 5000);
}
} else if (enabled) {
const testOk = await runSourceTest({ id });
if (testOk === false) {
const disablePayload = { source: { id, name, type, enabled: false, readOnly, config } };
try {
const disableRes = await fetch(withBase('/api/pro/sources/save.php'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
'Accept': 'application/json',
},
body: JSON.stringify(disablePayload),
});
const disableData = await safeJson(disableRes);
if (!disableData || disableData.ok !== true) {
throw new Error(disableData?.error || 'Failed to disable source after test failure.');
}
showToast(t('admin_source_test_failed_left_disabled'), 'error');
await loadSources();
} catch (disableErr) {
showToast(disableErr?.message || t('admin_source_test_failed_disable_error'), 'error');
}
}
} else {
setTestStatus(id, { state: 'idle', message: '' });
}
await refreshSourceSelectorSafe('admin-sources-save');
resetForm();
} catch (err) {
if (isNewSource) {
state.sources = (state.sources || []).filter(s => String(s.id || '') !== id);
if (state.testStatus) {
delete state.testStatus[id];
}
renderList();
} else {
setTestStatus(id, { state: 'error', message: err?.message || tf('error', 'Error') });
}
setStatus(err?.message || tf('error', 'Error'), 'danger');
showToast(err?.message || tf('error', 'Error'), 'error');
} finally {
setSavingState(false);
}
});
}
resetForm();
loadSources();
}
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)) || t('admin_oidc_snapshot_failed');
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(t('admin_oidc_snapshot_failed'), '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 (e) { /* 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() : "",
publishedUrl: (document.getElementById("publishedUrl")?.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,
oidcGroupClaim: (document.getElementById("oidcGroupClaim")?.value || "").trim(),
oidcExtraScopes: (document.getElementById("oidcExtraScopes")?.value || "").trim(),
// 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(),
resumableChunkMb: (document.getElementById("resumableChunkMb")?.value || "").trim(),
resumableTtlHours: (document.getElementById("resumableTtlHours")?.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(),
brandingMetaDescription: (document.getElementById("brandingMetaDescription")?.value || "").trim(),
brandingFaviconSvg: (document.getElementById("brandingFaviconSvg")?.value || "").trim(),
brandingFaviconPng: (document.getElementById("brandingFaviconPng")?.value || "").trim(),
brandingFaviconIco: (document.getElementById("brandingFaviconIco")?.value || "").trim(),
brandingAppleTouchIcon: (document.getElementById("brandingAppleTouchIcon")?.value || "").trim(),
brandingMaskIcon: (document.getElementById("brandingMaskIcon")?.value || "").trim(),
brandingMaskIconColor: (document.getElementById("brandingMaskIconColor")?.value || "").trim(),
brandingThemeColorLight: (document.getElementById("brandingThemeColorLight")?.value || "").trim(),
brandingThemeColorDark: (document.getElementById("brandingThemeColorDark")?.value || "").trim(),
brandingLoginBgLight: (document.getElementById("brandingLoginBgLight")?.value || "").trim(),
brandingLoginBgDark: (document.getElementById("brandingLoginBgDark")?.value || "").trim(),
brandingAppBgLight: (document.getElementById("brandingAppBgLight")?.value || "").trim(),
brandingAppBgDark: (document.getElementById("brandingAppBgDark")?.value || "").trim(),
brandingLoginTagline: (document.getElementById("brandingLoginTagline")?.value || "").trim(),
brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
defaultLanguage: (document.getElementById("defaultLanguage")?.value || "").trim(),
hoverPreviewMaxImageMb: (document.getElementById("hoverPreviewMaxImageMb")?.value || "").trim(),
hoverPreviewMaxVideoMb: (document.getElementById("hoverPreviewMaxVideoMb")?.value || "").trim(),
ffmpegPath: (document.getElementById("ffmpegPath")?.value || "").trim(),
fileListSummaryDepth: (document.getElementById("fileListSummaryDepth")?.value || "").trim(),
ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(),
clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked,
clamavExcludeDirs: (document.getElementById("clamavExcludeDirs")?.value || "").trim(),
proSearchEnabled: !!document.getElementById("proSearchEnabled")?.checked,
proSearchLimit: (document.getElementById("proSearchLimit")?.value || "").trim(),
proAuditEnabled: !!document.getElementById("proAuditEnabled")?.checked,
proAuditLevel: (document.getElementById("proAuditLevel")?.value || "").trim(),
proAuditMaxFileMb: (document.getElementById("proAuditMaxFileMb")?.value || "").trim(),
proAuditMaxFiles: (document.getElementById("proAuditMaxFiles")?.value || "").trim(),
};
}
function hasUnsavedChanges() {
const o = originalAdminConfig;
const getVal = id => (document.getElementById(id)?.value || "").trim();
const getChk = id => !!document.getElementById(id)?.checked;
return (
getVal("headerTitle") !== o.headerTitle ||
getVal("publishedUrl") !== (o.publishedUrl || "") ||
getVal("oidcProviderUrl") !== o.oidcProviderUrl ||
getVal("oidcClientId") !== o.oidcClientId ||
getVal("oidcClientSecret") !== o.oidcClientSecret ||
getVal("oidcRedirectUri") !== o.oidcRedirectUri ||
getChk("oidcAllowDemote") !== o.oidcAllowDemote ||
getChk("oidcDebugLogging") !== o.oidcDebugLogging ||
getVal("oidcGroupClaim") !== (o.oidcGroupClaim || "") ||
getVal("oidcExtraScopes") !== (o.oidcExtraScopes || "") ||
// 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("resumableChunkMb") !== o.resumableChunkMb ||
getVal("resumableTtlHours") !== o.resumableTtlHours ||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ||
getVal("brandingMetaDescription") !== (o.brandingMetaDescription || "") ||
getVal("brandingFaviconSvg") !== (o.brandingFaviconSvg || "") ||
getVal("brandingFaviconPng") !== (o.brandingFaviconPng || "") ||
getVal("brandingFaviconIco") !== (o.brandingFaviconIco || "") ||
getVal("brandingAppleTouchIcon") !== (o.brandingAppleTouchIcon || "") ||
getVal("brandingMaskIcon") !== (o.brandingMaskIcon || "") ||
getVal("brandingMaskIconColor") !== (o.brandingMaskIconColor || "") ||
getVal("brandingThemeColorLight") !== (o.brandingThemeColorLight || "") ||
getVal("brandingThemeColorDark") !== (o.brandingThemeColorDark || "") ||
getVal("brandingLoginBgLight") !== (o.brandingLoginBgLight || "") ||
getVal("brandingLoginBgDark") !== (o.brandingLoginBgDark || "") ||
getVal("brandingAppBgLight") !== (o.brandingAppBgLight || "") ||
getVal("brandingAppBgDark") !== (o.brandingAppBgDark || "") ||
getVal("brandingLoginTagline") !== (o.brandingLoginTagline || "") ||
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "") ||
getVal("defaultLanguage") !== (o.defaultLanguage || "") ||
getVal("hoverPreviewMaxImageMb") !== (o.hoverPreviewMaxImageMb || "") ||
getVal("hoverPreviewMaxVideoMb") !== (o.hoverPreviewMaxVideoMb || "") ||
getVal("ffmpegPath") !== (o.ffmpegPath || "") ||
getVal("fileListSummaryDepth") !== (o.fileListSummaryDepth || "") ||
getVal("ignoreRegex") !== (o.ignoreRegex || "") ||
getChk("clamavScanUploads") !== o.clamavScanUploads ||
getVal("clamavExcludeDirs") !== (o.clamavExcludeDirs || "") ||
getChk("proSearchEnabled") !== o.proSearchEnabled ||
getVal("proSearchLimit") !== o.proSearchLimit ||
getChk("proAuditEnabled") !== o.proAuditEnabled ||
getVal("proAuditLevel") !== o.proAuditLevel ||
getVal("proAuditMaxFileMb") !== o.proAuditMaxFileMb ||
getVal("proAuditMaxFiles") !== o.proAuditMaxFiles
);
}
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 showProUpdateChoiceModal({ hasAuto }) {
return new Promise(resolve => {
let modal = document.getElementById("proUpdateChoiceModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "proUpdateChoiceModal";
modal.className = "modal";
modal.style.zIndex = "4000";
modal.style.display = "none";
modal.innerHTML = `
<div class="modal-content" style="max-width:520px;">
<div id="proUpdateChoiceTitle" style="font-weight:600; margin-bottom:6px;"></div>
<div id="proUpdateChoiceMessage" style="white-space:pre-wrap; margin-bottom:10px;"></div>
<div class="modal-actions" style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
<button id="proUpdateChoiceAutoBtn" class="btn btn-primary">Download + install</button>
<button id="proUpdateChoiceManualBtn" class="btn btn-secondary">Manual download</button>
<button id="proUpdateChoiceCancelBtn" class="btn btn-outline-secondary">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
const titleEl = document.getElementById("proUpdateChoiceTitle");
const msgEl = document.getElementById("proUpdateChoiceMessage");
const autoBtn = document.getElementById("proUpdateChoiceAutoBtn");
const manualBtn = document.getElementById("proUpdateChoiceManualBtn");
const cancelBtn = document.getElementById("proUpdateChoiceCancelBtn");
if (!titleEl || !msgEl || !autoBtn || !manualBtn || !cancelBtn) {
resolve(null);
return;
}
titleEl.textContent = "Pro update available";
msgEl.textContent = hasAuto
? "Choose how you'd like to get the latest Pro bundle."
: "Save a license key to enable one-click download. You can still download manually.";
autoBtn.disabled = !hasAuto;
modal.style.display = "block";
const cleanup = () => {
modal.style.display = "none";
autoBtn.removeEventListener("click", onAuto);
manualBtn.removeEventListener("click", onManual);
cancelBtn.removeEventListener("click", onCancel);
};
const onAuto = () => { cleanup(); resolve("auto"); };
const onManual = () => { cleanup(); resolve("manual"); };
const onCancel = () => { cleanup(); resolve(null); };
autoBtn.addEventListener("click", onAuto);
manualBtn.addEventListener("click", onManual);
cancelBtn.addEventListener("click", onCancel);
});
}
function showTypedConfirmModal({ title, message, confirmText, placeholder }) {
return new Promise(resolve => {
let modal = document.getElementById("typedConfirmModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "typedConfirmModal";
modal.className = "modal";
modal.style.zIndex = "4000";
modal.style.display = "none";
modal.innerHTML = `
<div class="modal-content" style="max-width:520px;">
<div id="typedConfirmTitle" style="font-weight:600; margin-bottom:6px;"></div>
<div id="typedConfirmMessage" style="white-space:pre-wrap; margin-bottom:10px;"></div>
<input id="typedConfirmInput" class="form-control" type="text" autocomplete="off" />
<div class="modal-actions" style="margin-top:12px;">
<button id="typedConfirmYesBtn" class="btn btn-danger" disabled>Confirm</button>
<button id="typedConfirmNoBtn" class="btn btn-secondary">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
const titleEl = document.getElementById("typedConfirmTitle");
const msgEl = document.getElementById("typedConfirmMessage");
const input = document.getElementById("typedConfirmInput");
const yes = document.getElementById("typedConfirmYesBtn");
const no = document.getElementById("typedConfirmNoBtn");
if (!titleEl || !msgEl || !input || !yes || !no) {
resolve(false);
return;
}
titleEl.textContent = title || "Confirm";
msgEl.textContent = message || "";
input.value = "";
input.placeholder = placeholder || "";
yes.disabled = true;
modal.style.display = "block";
input.focus();
const onInput = () => {
yes.disabled = (input.value !== confirmText);
};
const cleanup = () => {
modal.style.display = "none";
input.removeEventListener("input", onInput);
yes.removeEventListener("click", onYes);
no.removeEventListener("click", onNo);
};
const onYes = () => { cleanup(); resolve(true); };
const onNo = () => { cleanup(); resolve(false); };
input.addEventListener("input", onInput);
yes.addEventListener("click", onYes);
no.addEventListener("click", onNo);
});
}
const SECTION_ANIM_MS = 160;
function clearSectionTimer(cnt) {
if (cnt && cnt.__closeTimer) {
clearTimeout(cnt.__closeTimer);
cnt.__closeTimer = null;
}
if (cnt && cnt.__focusTimer) {
clearTimeout(cnt.__focusTimer);
cnt.__focusTimer = null;
}
}
function openSectionContent(cnt) {
if (!cnt) return;
clearSectionTimer(cnt);
cnt.style.display = "block";
cnt.classList.remove("is-closing");
requestAnimationFrame(() => {
cnt.classList.add("is-open");
});
}
function closeSectionContent(cnt) {
if (!cnt) return;
clearSectionTimer(cnt);
cnt.classList.remove("is-open");
cnt.classList.add("is-closing");
cnt.__closeTimer = setTimeout(() => {
if (cnt.classList.contains("is-closing")) {
cnt.style.display = "none";
cnt.classList.remove("is-closing");
}
cnt.__closeTimer = null;
}, SECTION_ANIM_MS);
}
function setSectionContentImmediate(cnt, open) {
if (!cnt) return;
clearSectionTimer(cnt);
if (open) {
cnt.style.display = "block";
cnt.classList.add("is-open");
cnt.classList.remove("is-closing");
} else {
cnt.style.display = "none";
cnt.classList.remove("is-open", "is-closing");
}
}
function focusSectionIntoView(hdr, cnt) {
if (!hdr || !cnt) return;
const scroller = hdr.closest(".modal-content") || document.scrollingElement || document.documentElement;
if (!scroller) {
try {
hdr.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" });
} catch (e) {
hdr.scrollIntoView();
}
return;
}
const padding = 12;
const scrollerRect = scroller.getBoundingClientRect();
const headerRect = hdr.getBoundingClientRect();
const targetTop = scroller.scrollTop + (headerRect.top - scrollerRect.top) - padding;
const nextTop = Math.max(0, targetTop);
if (Math.abs(nextTop - scroller.scrollTop) < 1) return;
try {
if (typeof scroller.scrollTo === "function") {
scroller.scrollTo({ top: nextTop, behavior: "smooth" });
setTimeout(() => {
if (Math.abs(scroller.scrollTop - nextTop) > 1) {
scroller.scrollTop = nextTop;
}
}, 40);
return;
}
} catch (e) {
// Fallback for browsers that don't accept scroll options.
}
scroller.scrollTop = nextTop;
}
function scheduleSectionFocus(hdr, cnt) {
if (!hdr || !cnt) return;
clearSectionTimer(cnt);
const focusNow = () => {
if (cnt.style.display === "none") return;
focusSectionIntoView(hdr, cnt);
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
focusNow();
cnt.__focusTimer = setTimeout(() => {
if (cnt.classList.contains("is-open") && cnt.style.display !== "none") {
focusSectionIntoView(hdr, cnt);
}
cnt.__focusTimer = null;
}, SECTION_ANIM_MS + 40);
});
});
}
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");
if (isCollapsedNow) {
closeSectionContent(cnt);
} else {
openSectionContent(cnt);
scheduleSectionFocus(hdr, cnt);
}
if (!isCollapsedNow && id === "shareLinks") {
loadShareLinksSection();
cnt.dataset.loaded = "1";
}
}
function normalizeAdminSearchText(value) {
return String(value || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function wireAdminPanelSearch(sectionIds) {
const input = document.getElementById("adminSettingsSearch");
const emptyState = document.getElementById("adminSettingsSearchEmpty");
const wrap = document.getElementById("adminSettingsSearchWrap");
const toggleBtn = document.getElementById("adminSearchToggle");
const clearBtn = document.getElementById("adminSearchClear");
if (!input || !Array.isArray(sectionIds)) return;
const ids = sectionIds.filter(Boolean);
const setOpen = (open, opts = {}) => {
if (!wrap || !toggleBtn) return;
wrap.classList.toggle("is-collapsed", !open);
toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (open && opts.focus) {
input.focus();
}
};
const updateToggleState = () => {
const hasQuery = !!normalizeAdminSearchText(input.value);
if (toggleBtn) {
toggleBtn.classList.toggle("is-active", hasQuery);
}
if (clearBtn) {
clearBtn.hidden = !hasQuery;
clearBtn.disabled = !hasQuery;
}
};
const restoreState = () => {
ids.forEach(id => {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
if (!hdr || !cnt) return;
const saved = hdr.dataset.prevCollapsed;
hdr.style.display = "";
cnt.style.display = "";
if (saved !== undefined) {
const wasCollapsed = saved === "1";
hdr.classList.toggle("collapsed", wasCollapsed);
setSectionContentImmediate(cnt, !wasCollapsed);
delete hdr.dataset.prevCollapsed;
}
});
if (emptyState) emptyState.style.display = "none";
};
const applyFilter = () => {
const query = normalizeAdminSearchText(input.value);
if (!query) {
restoreState();
updateToggleState();
return;
}
let matches = 0;
ids.forEach(id => {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
if (!hdr || !cnt) return;
if (hdr.dataset.prevCollapsed === undefined) {
hdr.dataset.prevCollapsed = hdr.classList.contains("collapsed") ? "1" : "0";
}
const haystack = normalizeAdminSearchText(hdr.textContent + " " + cnt.textContent);
const match = haystack.includes(query);
hdr.style.display = match ? "" : "none";
setSectionContentImmediate(cnt, match);
if (match) {
hdr.classList.remove("collapsed");
matches += 1;
if (id === "shareLinks" && !cnt.dataset.loaded) {
loadShareLinksSection();
cnt.dataset.loaded = "1";
}
}
});
if (emptyState) emptyState.style.display = matches ? "none" : "block";
updateToggleState();
};
const clearSearch = (opts = {}) => {
input.value = "";
applyFilter();
if (opts.collapse) {
setOpen(false);
}
if (opts.focus) {
input.focus();
}
};
if (clearBtn && !clearBtn.__wired) {
clearBtn.__wired = true;
clearBtn.addEventListener("click", (e) => {
e.preventDefault();
clearSearch({ focus: true });
});
}
if (toggleBtn && wrap && !toggleBtn.__wired) {
toggleBtn.__wired = true;
toggleBtn.addEventListener("click", () => {
const isCollapsed = wrap.classList.contains("is-collapsed");
if (isCollapsed) {
setOpen(true, { focus: true });
return;
}
const hasQuery = !!normalizeAdminSearchText(input.value);
if (hasQuery) {
clearSearch({ collapse: true });
return;
}
setOpen(false);
});
}
input.addEventListener("input", applyFilter);
const initialQuery = normalizeAdminSearchText(input.value);
if (initialQuery) {
setOpen(true);
applyFilter();
} else {
setOpen(false);
updateToggleState();
}
}
export function initProBundleInstaller(options = {}) {
try {
const fileInput = document.getElementById('proBundleFile');
const btn = document.getElementById('btnInstallProBundle');
const dlBtn = document.getElementById('btnDownloadProBundle');
const statusEl = document.getElementById('proBundleStatus');
const updatesExpired = !!options.updatesExpired;
const updatesUntilLabel = options.updatesUntil ? String(options.updatesUntil) : '';
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;
}
if (updatesExpired) {
const when = updatesUntilLabel ? ` on ${updatesUntilLabel}` : '';
const ok = await showCustomConfirmModal(
`Updates expired${when}. Installing a newer Pro bundle will deactivate Pro. Continue only if this bundle is eligible for your license.`
);
if (!ok) {
return;
}
}
const formData = new FormData();
formData.append('bundle', file);
statusEl.textContent = 'Uploading and installing Pro bundle...';
statusEl.className = 'small text-muted';
const progress = startProBundleProgress({
action: 'Installing Pro',
title: 'Installing FileRise Pro bundle'
});
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';
finishProBundleProgress(progress, false, msg);
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';
finishProBundleProgress(progress, true);
// 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) {
const errMsg = e && e.message ? e.message : String(e);
statusEl.textContent = 'Install failed: ' + errMsg;
statusEl.className = 'small text-danger';
finishProBundleProgress(progress, false, errMsg);
}
});
if (dlBtn && !dlBtn.__wired) {
dlBtn.__wired = true;
dlBtn.addEventListener('click', async () => {
if (updatesExpired) {
const when = updatesUntilLabel ? ` on ${updatesUntilLabel}` : '';
statusEl.textContent = `Updates expired${when}. Renew to download newer Pro bundles.`;
statusEl.className = 'small text-warning';
return;
}
const ok = await showCustomConfirmModal(
"Download and install the latest Pro bundle from filerise.net? This will overwrite the current Pro files."
);
if (!ok) return;
dlBtn.disabled = true;
statusEl.textContent = 'Downloading and installing latest Pro bundle...';
statusEl.className = 'small text-muted';
const progress = startProBundleProgress({
action: 'Updating Pro',
title: 'Downloading and installing FileRise Pro bundle'
});
try {
const resp = await fetch('/api/admin/downloadProBundle.php', {
method: 'POST',
headers: {
'X-CSRF-Token': window.csrfToken || ''
},
credentials: 'include'
});
let data = null;
try {
data = await resp.json();
} catch (_) {
data = null;
}
if (!resp.ok || !data || !data.success) {
const msg = data && (data.error || data.message)
? (data.error || data.message)
: `HTTP ${resp.status}`;
statusEl.textContent = 'Download/install failed: ' + msg;
statusEl.className = 'small text-danger';
finishProBundleProgress(progress, false, msg);
return;
}
const finalVersion = data.proVersion ? String(data.proVersion) : '';
const versionText = finalVersion ? ` (version ${finalVersion})` : '';
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reloading...';
statusEl.className = 'small text-success';
finishProBundleProgress(progress, true);
if (typeof loadAdminConfigFunc === 'function') {
loadAdminConfigFunc();
}
setTimeout(() => {
window.location.reload();
}, 800);
} catch (e) {
const errMsg = e && e.message ? e.message : String(e);
statusEl.textContent = 'Download/install failed: ' + errMsg;
statusEl.className = 'small text-danger';
finishProBundleProgress(progress, false, errMsg);
} finally {
dlBtn.disabled = false;
}
});
}
} 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: isAdminUser(u)
};
});
}
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, updateUserMetaCache);
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 (e) {
// 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, updateUserMetaCache);
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, updateUserMetaCache);
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, updateUserMetaCache);
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 esc = (val) => escapeHTML(val == null ? "" : String(val));
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 ? "🔒 " : "";
const tokenValue = o.token || token;
const sourceLabel = o.sourceName || o.sourceId || "";
const sourceHtml = sourceLabel && sourceLabel.toLowerCase() !== "local"
? ` <small class="text-muted">[${esc(sourceLabel)}]</small>`
: "";
const folderLabel = esc(o.folder || "root");
html += `
<li>
${lock}<strong>${folderLabel}</strong>${sourceHtml}
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button"
data-key="${esc(tokenValue)}"
data-source-id="${esc(o.sourceId || "")}"
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 ? "🔒 " : "";
const tokenValue = o.token || token;
const sourceLabel = o.sourceName || o.sourceId || "";
const sourceHtml = sourceLabel && sourceLabel.toLowerCase() !== "local"
? ` <small class="text-muted">[${esc(sourceLabel)}]</small>`
: "";
const folderLabel = esc(o.folder || "root");
const fileLabel = esc(o.file || "");
const pathLabel = fileLabel ? `${folderLabel}/${fileLabel}` : folderLabel;
html += `
<li>
${lock}<strong>${pathLabel}</strong>${sourceHtml}
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button"
data-key="${esc(tokenValue)}"
data-source-id="${esc(o.sourceId || "")}"
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 sourceId = btn.dataset.sourceId || "";
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 });
if (sourceId) {
body.set("sourceId", sourceId);
}
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(t('admin_share_delete_forbidden'), '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 => {
const rawHeaderTitle = (typeof config.header_title === 'string') ? config.header_title : '';
window.headerTitle = rawHeaderTitle;
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 headerDisplayTitle = resolveHeaderTitle(rawHeaderTitle, isPro);
const h = document.querySelector(".header-title h1");
if (h) h.textContent = headerDisplayTitle;
document.title = headerDisplayTitle;
const proType = proInfo.type || '';
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
const proApiLevel = Number(proInfo.apiLevel || 0);
const proBuildEpoch = Number(proInfo.buildEpoch || 0);
const proSearchApiOk = isPro && proApiLevel >= PRO_API_LEVELS.search;
const proAuditApiOk = isPro && proApiLevel >= PRO_API_LEVELS.audit;
const proSourcesApiOk = isPro && proApiLevel >= PRO_API_LEVELS.sources;
const proSearchOptOut = !!(config.proSearch && config.proSearch.optOut);
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 proUpdatesUntil = proInfo.updatesUntil || proInfo.expiresAt || ''; // ISO timestamp string or ""
const proInstanceId = proInfo.instanceId || '';
const proPrimaryAdmin = Object.prototype.hasOwnProperty.call(proInfo, 'primaryAdmin')
? !!proInfo.primaryAdmin
: true;
const proMaxMajor = (
typeof proInfo.maxMajor === 'number'
? proInfo.maxMajor
: (proInfo.maxMajor ? Number(proInfo.maxMajor) : null)
);
const proSearchCfg = (config.proSearch && typeof config.proSearch === 'object')
? config.proSearch
: {};
const updatesExpired = (() => {
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
return false;
}
if (!proUpdatesUntil) {
return false;
}
const ts = Date.parse(proUpdatesUntil);
return Number.isFinite(ts) && ts < Date.now();
})();
const proSearchExplicitDisabled = Object.prototype.hasOwnProperty.call(proSearchCfg, 'enabled') && !proSearchCfg.enabled;
const proSearchOptOutEffective = proSearchOptOut || proSearchExplicitDisabled;
let proSearchEnabled = proSearchApiOk && !!proSearchCfg.enabled;
// Auto-enable when Pro API level supports Search Everywhere unless explicitly opted out/disabled or env locked
if (proSearchApiOk && !proSearchOptOutEffective) {
proSearchEnabled = true;
}
const proSearchDefaultLimit = Math.max(
1,
Math.min(200, parseInt(proSearchCfg.defaultLimit || 50, 10) || 50)
);
const proSearchLocked = !!proSearchCfg.lockedByEnv;
const proAuditCfg = (config.proAudit && typeof config.proAudit === 'object')
? config.proAudit
: {};
const proAuditAvailable = !!proAuditCfg.available && proAuditApiOk;
const proAuditEnabled = (isPro && proAuditAvailable) ? !!proAuditCfg.enabled : false;
const proAuditLevelRaw = (typeof proAuditCfg.level === 'string') ? proAuditCfg.level : 'verbose';
const proAuditLevel = (proAuditLevelRaw === 'standard' || proAuditLevelRaw === 'verbose')
? proAuditLevelRaw
: 'verbose';
const proAuditMaxFileMb = Math.max(10, parseInt(proAuditCfg.maxFileMb || 200, 10) || 200);
const proAuditMaxFiles = Math.max(1, Math.min(10, parseInt(proAuditCfg.maxFiles || 10, 10) || 10));
const sourcesCfg = (config.storageSources && typeof config.storageSources === 'object')
? config.storageSources
: {};
const sourcesEnabled = !!sourcesCfg.enabled;
const showSourcesSection = true;
const brandingCfg = config.branding || {};
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
const brandingMetaDescription = brandingCfg.metaDescription || "";
const brandingFaviconSvg = brandingCfg.faviconSvg || "";
const brandingFaviconPng = brandingCfg.faviconPng || "";
const brandingFaviconIco = brandingCfg.faviconIco || "";
const brandingAppleTouchIcon = brandingCfg.appleTouchIcon || "";
const brandingMaskIcon = brandingCfg.maskIcon || "";
const brandingMaskIconColor = brandingCfg.maskIconColor || "";
const brandingThemeColorLight = brandingCfg.themeColorLight || "";
const brandingThemeColorDark = brandingCfg.themeColorDark || "";
const brandingLoginBgLight = brandingCfg.loginBgLight || "";
const brandingLoginBgDark = brandingCfg.loginBgDark || "";
const brandingAppBgLight = brandingCfg.appBgLight || "";
const brandingAppBgDark = brandingCfg.appBgDark || "";
const brandingLoginTagline = brandingCfg.loginTagline || "";
const brandingFooterHtml = brandingCfg.footerHtml || "";
const displayCfg = (config.display && typeof config.display === 'object') ? config.display : {};
const ffmpegPathCfg = (typeof config.ffmpegPath === 'string') ? config.ffmpegPath : '';
const ffmpegPathEffective = (typeof config.ffmpegPathEffective === 'string') ? config.ffmpegPathEffective : '';
const ffmpegPathLockedByEnv = !!config.ffmpegPathLockedByEnv;
const ffmpegPathValue = ffmpegPathLockedByEnv ? ffmpegPathEffective : ffmpegPathCfg;
const ffmpegHelpDefault = 'Used for video thumbnail generation. Leave blank to use ffmpeg from PATH.';
const ffmpegHelpLocked = 'Controlled by container env FR_FFMPEG_PATH. Change it in your Docker/host env.';
const ignoreRegexCfg = (typeof config.ignoreRegex === 'string') ? config.ignoreRegex : '';
const ignoreRegexEffective = (typeof config.ignoreRegexEffective === 'string') ? config.ignoreRegexEffective : '';
const ignoreRegexLockedByEnv = !!config.ignoreRegexLockedByEnv;
const ignoreRegexValue = ignoreRegexLockedByEnv ? ignoreRegexEffective : ignoreRegexCfg;
const supportedLanguages = [
{ code: 'en', labelKey: 'english', fallback: 'English' },
{ code: 'es', labelKey: 'spanish', fallback: 'Español' },
{ code: 'fr', labelKey: 'french', fallback: 'Français' },
{ code: 'de', labelKey: 'german', fallback: 'Deutsch' },
{ code: 'pl', labelKey: 'polish', fallback: 'Polski' },
{ code: 'ru', labelKey: 'russian', fallback: 'Русский' },
{ code: 'ja', labelKey: 'japanese', fallback: '日本語' },
{ code: 'zh-CN', labelKey: 'chinese_simplified', fallback: '简体中文' },
];
const defaultLanguage = supportedLanguages.some(lang => lang.code === displayCfg.defaultLanguage)
? displayCfg.defaultLanguage
: 'en';
const defaultLanguageOptions = supportedLanguages.map(({ code, labelKey, fallback }) => {
const label = ((typeof t === 'function' ? t(labelKey) : '') || fallback).replace(/\"/g, '&quot;');
const selected = code === defaultLanguage ? ' selected' : '';
return `<option value="${code}" data-i18n-key="${labelKey}"${selected}>${label}</option>`;
}).join('');
const hoverPreviewMaxImageMb = Math.max(
1,
Math.min(50, parseInt(displayCfg.hoverPreviewMaxImageMb || 8, 10) || 8)
);
const hoverPreviewMaxVideoMb = Math.max(
1,
Math.min(2048, parseInt(displayCfg.hoverPreviewMaxVideoMb || 200, 10) || 200)
);
const rawSummaryDepth = parseInt(displayCfg.fileListSummaryDepth, 10);
const fileListSummaryDepth = Math.max(
0,
Math.min(10, Number.isFinite(rawSummaryDepth) ? rawSummaryDepth : 2)
);
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;
`;
const sections = [
{ id: "userManagement", label: tf("users_access", "Users & Access") },
{ id: "headerSettings", label: tf("appearance_ui", "Appearance, UI & Indexing") },
{ id: "loginOptions", label: tf("auth_webdav", "Auth & WebDAV (OIDC/TOTP)") },
{ id: "upload", label: tf("uploads_antivirus", "Uploads & Antivirus") },
{ id: "shareLinks", label: tf("sharing_links", "Sharing & Links") },
{ id: "network", label: tf("network_proxy", "Network & Proxy") },
{ id: "encryption", label: tf("encryption_at_rest", "Encryption at rest") },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "storage", label: tf("storage_usage", "Storage / Disk Usage") }
];
if (showSourcesSection) {
const sourcesLabel = !isPro
? `<span style="display:inline-flex; align-items:center; gap:6px;">${tf("sources", "Sources")}<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span></span>`
: tf("sources", "Sources");
sections.push({ id: "sources", label: sourcesLabel });
}
sections.push(
{ id: "proFeatures", label: "Pro Features" },
{ id: "pro", label: "FileRise Pro" },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Thanks / Sponsor / Donations") : "Thanks / Sponsor / Donations") }
);
const sectionIds = sections.map(sec => sec.id);
mdl.innerHTML = `
<div class="modal-content" style="${inner}">
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<div class="admin-panel-header">
<h3>${getAdminTitle(isPro, proVersion, updatesExpired)}</h3>
<div class="admin-panel-actions">
<button
type="button"
id="adminSearchToggle"
class="btn btn-sm btn-light admin-search-toggle"
title="${tf("settings_search_toggle", "Search settings")}"
aria-expanded="false"
>
<i class="material-icons">search</i>
<span class="admin-search-toggle-label">${tf("search", "Search")}</span>
</button>
</div>
</div>
<form id="adminPanelForm">
<div id="adminSettingsSearchWrap" class="form-group admin-search-wrap is-collapsed">
<div class="admin-search-input">
<input
type="text"
id="adminSettingsSearch"
class="form-control"
autocomplete="off"
placeholder="${tf("settings_search_placeholder", "Search settings...")}"
/>
<button
type="button"
id="adminSearchClear"
class="admin-search-clear"
aria-label="${tf("clear_search", "Clear search")}"
title="${tf("clear_search", "Clear search")}"
hidden
>
<i class="material-icons" aria-hidden="true">close</i>
</button>
</div>
<small class="text-muted d-block mt-1">
${tf("settings_search_help", "Type to filter sections and settings.")}
</small>
<div id="adminSettingsSearchEmpty" class="small text-muted" style="display:none; margin-top:6px;">
${tf("settings_search_empty", "No matching settings found.")}
</div>
</div>
${sections.map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
<div class="section-header-inner">
${sec.label} <i class="material-icons">expand_more</i>
</div>
</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);
wireAdminPanelSearch(sectionIds);
sectionIds.filter(Boolean).forEach(id => {
const headerEl = document.getElementById(id + "Header");
if (!headerEl || headerEl.__wired) return;
headerEl.__wired = true;
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>
`;
if (showSourcesSection) {
initSourcesSection({
modalEl: mdl,
sourcesEnabled,
sourcesCfg,
isPro,
proSourcesApiOk
});
}
// 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(t('admin_pro_feature_user_groups'));
window.open("https://filerise.net", "_blank", "noopener");
return;
}
openUserGroupsModal();
});
}
const clientBtn = document.getElementById("adminOpenClientPortal");
if (clientBtn) {
clientBtn.addEventListener("click", () => {
if (!isPro) {
showToast(t('admin_pro_feature_portals'));
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>
<div class="form-group" style="margin-top:16px;">
<label for="defaultLanguage">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("default_language", "Default language")}
</div>
</label>
<small class="text-muted d-block mb-1">
${tf("default_language_help", "Used when a user has not chosen a language yet.")}
</small>
<select id="defaultLanguage" class="form-control">
${defaultLanguageOptions}
</select>
</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: Meta description -->
<div class="form-group" style="margin-top:16px;">
<label for="brandingMetaDescription">
<div class="admin-subsection-title" style="margin-top:2px;">
Meta description
</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 in the <meta name="description"> tag on index.html. Recommended 50-160 characters.'
: 'Requires FileRise Pro to customize the site description.'}
</small>
<textarea
id="brandingMetaDescription"
class="form-control"
rows="2"
placeholder="FileRise is a fast, self-hosted file manager for your team."
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}>${isPro ? (brandingMetaDescription || '') : ''}</textarea>
</div>
<hr class="admin-divider">
<!-- Pro: Favicons + theme color -->
<div class="form-group" style="margin-top:16px;">
<label>
<div class="admin-subsection-title" style="margin-top:2px;">
Favicons & browser theme color
</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-2">
${isPro
? 'Use site-relative paths or full URLs. PNG replaces all PNG favicon sizes. Theme colors tint the browser UI on mobile/PWA. Mask icon color applies to Safari pinned tabs (requires the mask icon SVG).'
: 'Requires FileRise Pro to customize favicons and theme colors.'}
</small>
<div class="form-group">
<label for="brandingFaviconSvg" class="d-block" style="font-size:12px;">Favicon (SVG)</label>
<input
type="text"
id="brandingFaviconSvg"
class="form-control"
placeholder="/assets/logo.svg"
value="${isPro ? (brandingFaviconSvg.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="form-group">
<label for="brandingFaviconPng" class="d-block" style="font-size:12px;">Favicon (PNG)</label>
<input
type="text"
id="brandingFaviconPng"
class="form-control"
placeholder="/assets/logo-32.png"
value="${isPro ? (brandingFaviconPng.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="form-group">
<label for="brandingFaviconIco" class="d-block" style="font-size:12px;">Favicon (ICO)</label>
<input
type="text"
id="brandingFaviconIco"
class="form-control"
placeholder="/assets/favicon.ico"
value="${isPro ? (brandingFaviconIco.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="form-group">
<label for="brandingAppleTouchIcon" class="d-block" style="font-size:12px;">Apple touch icon</label>
<input
type="text"
id="brandingAppleTouchIcon"
class="form-control"
placeholder="/assets/icons/icon-192.png"
value="${isPro ? (brandingAppleTouchIcon.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="form-group">
<label for="brandingMaskIcon" class="d-block" style="font-size:12px;">Pinned tab mask icon (SVG)</label>
<input
type="text"
id="brandingMaskIcon"
class="form-control"
placeholder="/assets/icons/safari-pinned-tab.svg"
value="${isPro ? (brandingMaskIcon.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="d-flex align-items-center" style="gap:12px; flex-wrap:wrap;">
<div style="min-width:180px;">
<label for="brandingMaskIconColor" class="d-block" style="font-size:12px;">Mask icon color</label>
<input
type="text"
id="brandingMaskIconColor"
class="form-control"
placeholder="#0b5ed7"
value="${isPro ? (brandingMaskIconColor.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div style="min-width:180px;">
<label for="brandingThemeColorLight" class="d-block" style="font-size:12px;">Theme color (light)</label>
<input
type="text"
id="brandingThemeColorLight"
class="form-control"
placeholder="#0b5ed7"
value="${isPro ? (brandingThemeColorLight.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div style="min-width:180px;">
<label for="brandingThemeColorDark" class="d-block" style="font-size:12px;">Theme color (dark)</label>
<input
type="text"
id="brandingThemeColorDark"
class="form-control"
placeholder="#121212"
value="${isPro ? (brandingThemeColorDark.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
</div>
<small class="text-muted d-block mt-2">
Mask icon color is visible in Safari pinned tabs only. Theme colors change the browser chrome, not the page background.
</small>
</div>
<hr class="admin-divider">
<!-- Pro: Login page background -->
<div class="form-group" style="margin-top:16px;">
<label>
<div class="admin-subsection-title" style="margin-top:2px;">
Login page background
</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-2">
${isPro
? 'Accepts any CSS background value (color, gradient, or url(...)).'
: 'Requires FileRise Pro to customize the login background.'}
</small>
<div class="form-group">
<label for="brandingLoginBgLight" class="d-block" style="font-size:12px;">Light mode background</label>
<textarea
id="brandingLoginBgLight"
class="form-control"
rows="2"
placeholder="radial-gradient(circle at 20% 20%, rgba(11,94,215,0.15), transparent 55%)"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}>${isPro ? (brandingLoginBgLight || '') : ''}</textarea>
</div>
<div class="form-group">
<label for="brandingLoginBgDark" class="d-block" style="font-size:12px;">Dark mode background</label>
<textarea
id="brandingLoginBgDark"
class="form-control"
rows="2"
placeholder="linear-gradient(180deg, #0b1220 0%, #0f172a 100%)"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}>${isPro ? (brandingLoginBgDark || '') : ''}</textarea>
</div>
<div class="form-group">
<label for="brandingLoginTagline" class="d-block" style="font-size:12px;">Login tagline (optional)</label>
<input
type="text"
id="brandingLoginTagline"
class="form-control"
placeholder="Secure files. Simple workflow."
value="${isPro ? (brandingLoginTagline.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
</div>
<hr class="admin-divider">
<!-- Pro: App background -->
<div class="form-group" style="margin-top:16px;">
<label>
<div class="admin-subsection-title" style="margin-top:2px;">
App background (after login)
</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-2">
${isPro
? 'Accepts any CSS background value (color, gradient, or url(...)). Leave blank to use FileRise defaults.'
: 'Requires FileRise Pro to customize the app background.'}
</small>
<div class="form-group">
<label for="brandingAppBgLight" class="d-block" style="font-size:12px;">Light mode background</label>
<input
type="text"
id="brandingAppBgLight"
class="form-control"
placeholder="linear-gradient(135deg,#0ea5e9,#22c55e)"
value="${isPro ? (brandingAppBgLight.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="form-group">
<label for="brandingAppBgDark" class="d-block" style="font-size:12px;">Dark mode background</label>
<input
type="text"
id="brandingAppBgDark"
class="form-control"
placeholder="linear-gradient(180deg,#0b1220,#0f172a)"
value="${isPro ? (brandingAppBgDark.replace(/\"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<small class="text-muted d-block mt-1">
${tf("app_bg_color_help", "Tip: use the same CSS background format as the login screen. This affects the main app, not the login screen.")}
</small>
</div>
<hr class="admin-divider">
<!-- Display: Hover preview max image size -->
<div class="form-group" style="margin-top:16px;">
<label for="hoverPreviewMaxImageMb">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("hover_preview_max_image_mb", "Hover preview max image size (MB)")}
</div>
</label>
<small class="text-muted d-block mb-1">
${tf("hover_preview_max_image_help", "Applies to hover previews and gallery thumbnails. Default 8 MB; higher values increase bandwidth and memory use.")}
</small>
<input
type="number"
id="hoverPreviewMaxImageMb"
class="form-control"
min="1"
max="50"
step="1"
value="${hoverPreviewMaxImageMb}"
/>
</div>
<!-- Display: Hover preview max video size -->
<div class="form-group" style="margin-top:16px;">
<label for="hoverPreviewMaxVideoMb">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("hover_preview_max_video_mb", "Hover preview max video size (MB)")}
</div>
</label>
<small class="text-muted d-block mb-1">
${tf("hover_preview_max_video_help", "Applies to hover previews and gallery thumbnails. Default 200 MB; higher values can increase bandwidth on large videos.")}
</small>
<input
type="number"
id="hoverPreviewMaxVideoMb"
class="form-control"
min="1"
max="2048"
step="1"
value="${hoverPreviewMaxVideoMb}"
/>
</div>
<!-- Display: FFmpeg path -->
<div class="form-group" style="margin-top:16px;">
<label for="ffmpegPath">
<div class="admin-subsection-title" style="margin-top:2px;">
FFmpeg binary path (optional)
</div>
</label>
<small id="ffmpegPathHelp" class="text-muted d-block mb-1">
${ffmpegPathLockedByEnv ? ffmpegHelpLocked : ffmpegHelpDefault}
</small>
<input
type="text"
id="ffmpegPath"
class="form-control"
placeholder="/usr/local/bin/ffmpeg"
value="${(ffmpegPathValue || '').replace(/\"/g, '&quot;')}"
${ffmpegPathLockedByEnv ? "disabled data-locked='1'" : ""}
/>
</div>
<!-- Display: File list summary depth -->
<div class="form-group" style="margin-top:16px;">
<label for="fileListSummaryDepth">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("file_list_summary_depth", "File list summary depth")}
</div>
</label>
<small class="text-muted d-block mb-1">
${tf("file_list_summary_depth_help", "Caps recursive folder totals. 0 = unlimited, 1 = children only, 2 = grandchildren, etc.")}
</small>
<input
type="number"
id="fileListSummaryDepth"
class="form-control"
min="0"
max="10"
step="1"
value="${fileListSummaryDepth}"
/>
</div>
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("indexing_ignore_rules", "Indexing ignore rules")}
</div>
<div class="form-group" style="margin-top:8px;">
<label for="ignoreRegex">${tf("ignore_regex_label", "Ignore paths (regex)")}</label>
<textarea
id="ignoreRegex"
class="form-control"
rows="3"
placeholder="(^|/)(@?snapshots?)(/|$)"
${ignoreRegexLockedByEnv ? "disabled data-locked='1'" : ""}>${escapeHTML(ignoreRegexValue || "")}</textarea>
<small class="text-muted d-block mt-1">
${tf("ignore_regex_help", "One pattern per line. Matches entry name or relative path from root (no leading slash; e.g. \"projects/snapshot/2024\").")}
</small>
<small class="text-muted d-block mt-1">
${tf("ignore_regex_scope_note", "Affects folder tree, counts, and indexing. Dot-prefixed entries (like .snapshots) are already hidden; use this for non-dot snapshot folders (snapshot, @snapshots).")}
</small>
<small class="text-muted d-block mt-1">
Built-in ignores: dot-prefixed entries (including .snapshots), <code>@eaDir</code>, <code>#recycle</code>,
<code>.DS_Store</code>, <code>Thumbs.db</code>, <code>trash</code>, <code>profile_pics</code>.
</small>
<div class="d-flex flex-wrap align-items-center" style="gap:8px; margin-top:6px;">
<span class="text-muted small">Quick add:</span>
<button
type="button"
id="ignoreRegexSnapshotsPreset"
class="btn btn-sm btn-outline-secondary"
${ignoreRegexLockedByEnv ? "disabled" : ""}>
Add snapshot preset
</button>
</div>
<small class="text-muted d-block mt-1">
${ignoreRegexLockedByEnv
? `Env <code>FR_IGNORE_REGEX</code> overrides and locks this field.`
: `Env <code>FR_IGNORE_REGEX</code> overrides this field when set.`}
</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(t('admin_logo_choose_image'));
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 || t('admin_logo_upload_error'));
return;
}
const normalized = normalizeLogoPath(js.url); // your helper
urlInput.value = normalized;
showToast(t('admin_logo_uploaded_reminder'));
} catch (e) {
console.error(e);
showToast(t('admin_logo_upload_error'));
}
});
}
}
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>
`;
// --- Firewall / Proxy Settings (Published URL) ---
const deployInfo = (config && typeof config === 'object' && config.deployment && typeof config.deployment === 'object')
? config.deployment
: {};
const publishedLocked = !!deployInfo.publishedUrlLockedByEnv;
const publishedEffective = (deployInfo.publishedUrlEffective || '').toString();
const publishedCfg = (deployInfo.publishedUrl || '').toString();
const basePathEff = (deployInfo.basePath || '').toString();
const shareUrlEff = (deployInfo.shareUrl || '').toString();
document.getElementById("networkContent").innerHTML = `
<div class="admin-subsection-title">
${tf("published_server_uris", "Published server URIs")}
</div>
<div class="form-group">
<label for="publishedUrl">${tf("published_url_label", "Published URL (optional)")}</label>
<input
type="url"
id="publishedUrl"
class="form-control"
placeholder="https://example.com/fr"
${publishedLocked ? "disabled data-locked='1'" : ""}
/>
<small class="text-muted d-block mt-1">
${tf(
"published_url_help",
"Overrides the base URL FileRise uses when generating share links and redirects (useful behind reverse proxies and subpath installs). Leave blank to use auto-detection."
)}
</small>
${publishedLocked ? `
<small class="text-muted d-block mt-1">
Controlled by env <code>FR_PUBLISHED_URL</code>.
</small>` : ``}
</div>
<hr class="admin-divider">
<div class="form-group">
<label>${tf("effective_base_path", "Effective base path")}</label>
<input type="text" class="form-control" value="${escapeHTML(basePathEff || (window.__FR_BASE_PATH__ || ""))}" disabled />
</div>
<div class="form-group">
<label>${tf("effective_share_url", "Effective share URL")}</label>
<input type="text" class="form-control" value="${escapeHTML(shareUrlEff || "")}" disabled />
</div>
<div class="form-group">
<label>${tf("effective_published_url", "Effective published URL")}</label>
<input type="text" class="form-control" value="${escapeHTML(publishedEffective || "")}" disabled />
</div>
`;
renderAdminEncryptionSection({ config, dark });
document.getElementById("uploadContent").innerHTML = `
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("upload_settings", "Upload settings")}
</div>
<div class="form-group" style="margin-top:10px;">
<label for="resumableChunkMb">
${tf("resumable_chunk_size_label", "Resumable chunk size (MB)")}
</label>
<input
type="number"
id="resumableChunkMb"
class="form-control"
min="0.5"
max="100"
step="0.1"
/>
<small class="text-muted d-block mt-1">
${tf(
"resumable_chunk_size_help",
"Applies to file picker uploads (Resumable.js). Use smaller chunks if your proxy limits request size (e.g., Cloudflare Tunnels 100 MB)."
)}
</small>
</div>
<div class="form-group" style="margin-top:10px;">
<label for="resumableTtlHours">
${tf("resumable_cleanup_hours_label", "Resumable cleanup age (hours)")}
</label>
<input
type="number"
id="resumableTtlHours"
class="form-control"
min="0.5"
max="168"
step="0.5"
/>
<small class="text-muted d-block mt-1">
${tf(
"resumable_cleanup_hours_help",
"Deletes unfinished resumable uploads after this age. Applies to background sweeps and manual cleanup."
)}
</small>
</div>
<div class="mt-2">
<button
type="button"
id="resumableCleanupNowBtn"
class="btn btn-sm btn-secondary">
${tf("resumable_cleanup_run_now", "Run cleanup now")}
</button>
<small class="text-muted d-block" style="margin-top:4px;">
${tf(
"resumable_cleanup_run_now_help",
"Immediately sweeps expired resumable temp folders using the age above."
)}
</small>
<div id="resumableCleanupStatus" class="small text-muted" style="margin-top:4px;"></div>
<div class="small text-muted" style="margin-top:6px;">
<div style="font-weight:600;">
${tf("resumable_cleanup_cron_title", "Cron example")}
</div>
<div>
${tf(
"resumable_cleanup_cron_help",
"Example hourly job: <code>0 * * * * /usr/bin/php /path/to/FileRise/src/cli/resumable_cleanup.php --all --respect-interval</code>"
)}
</div>
<div>
${tf(
"resumable_cleanup_cron_note",
"Replace /path/to/FileRise with your install path."
)}
</div>
</div>
</div>
<hr class="admin-divider">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("antivirus_upload_scanning", "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="form-group" style="margin-top:10px;">
<label for="clamavExcludeDirs">
${tf("clamav_exclude_dirs_label", "Exclude upload paths")}
</label>
<textarea
id="clamavExcludeDirs"
class="form-control"
rows="2"
placeholder="${escapeHTML(tf("clamav_exclude_dirs_placeholder", "snapshot, tmp"))}"
></textarea>
<small
id="clamavExcludeDirsHelp"
class="d-block text-muted"
style="margin-top:2px;"
>
${tf(
"clamav_exclude_dirs_help",
"Comma or newline separated paths relative to the source root (example: snapshot, tmp). For Pro sources you can prefix with source id (example: s3:/snapshot)."
)}
</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>
`
}
`;
const uploadScope = document.getElementById("uploadContent");
const headerSettingsScope = document.getElementById("headerSettingsContent");
wireIgnoreRegexPresetButton(headerSettingsScope);
wireClamavTestButton(uploadScope);
wireResumableCleanupButton(uploadScope);
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);
const oidcPublicClient = !!(config.oidc && config.oidc.publicClient);
const oidcGroupClaimCfg = (config.oidc && typeof config.oidc.groupClaim === 'string')
? config.oidc.groupClaim
: '';
const oidcGroupClaimEffective = (config.oidc && typeof config.oidc.groupClaimEffective === 'string')
? config.oidc.groupClaimEffective
: '';
const oidcGroupClaimLocked = !!(config.oidc && config.oidc.groupClaimLockedByEnv);
const oidcGroupClaimValue = oidcGroupClaimLocked ? oidcGroupClaimEffective : oidcGroupClaimCfg;
const oidcExtraScopesCfg = (config.oidc && typeof config.oidc.extraScopes === 'string')
? config.oidc.extraScopes
: '';
const oidcExtraScopesEffective = (config.oidc && typeof config.oidc.extraScopesEffective === 'string')
? config.oidc.extraScopesEffective
: '';
const oidcExtraScopesLocked = !!(config.oidc && config.oidc.extraScopesLockedByEnv);
const oidcExtraScopesValue = oidcExtraScopesLocked ? oidcExtraScopesEffective : oidcExtraScopesCfg;
const oidcGroupClaimHelpDefault = tf(
"oidc_group_claim_help",
"Claim name for IdP groups (supports dot paths like realm_access.roles). Leave blank to use \"groups\"."
);
const oidcGroupClaimHelpLocked = tf(
"oidc_group_claim_locked",
"Locked by FR_OIDC_GROUP_CLAIM env override."
);
const oidcGroupClaimHelp = oidcGroupClaimLocked ? oidcGroupClaimHelpLocked : oidcGroupClaimHelpDefault;
const oidcExtraScopesHelpDefault = tf(
"oidc_extra_scopes_help",
"Space/comma-separated scopes to request in addition to openid/profile/email (example: groups)."
);
const oidcExtraScopesHelpLocked = tf(
"oidc_extra_scopes_locked",
"Locked by FR_OIDC_EXTRA_SCOPES env override."
);
const oidcExtraScopesHelp = oidcExtraScopesLocked ? oidcExtraScopesHelpLocked : oidcExtraScopesHelpDefault;
const oidcHtml = `
<hr class="admin-divider">
<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" style="margin-top:6px;">
<div class="form-check fr-toggle">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="oidcPublicClient"
${oidcPublicClient ? 'checked' : ''} />
<label class="form-check-label" for="oidcPublicClient">
${tf("oidc_public_client_label", "This is a public OIDC client (no client secret)")}
</label>
</div>
<small class="text-muted d-block mt-1">
${tf("oidc_public_client_help", "Uses PKCE (S256) with token auth method \"none\". Leave unchecked for confidential clients that send a client secret.")}
</small>
</div>
<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>
<div class="form-group">
<label for="oidcGroupClaim">${tf("oidc_group_claim_label", "Group claim name")}:</label>
<input type="text" id="oidcGroupClaim" class="form-control"
placeholder="groups"
value="${escapeHTML(oidcGroupClaimValue || "")}"
${oidcGroupClaimLocked ? "disabled data-locked='1'" : ""} />
<small class="text-muted">${escapeHTML(oidcGroupClaimHelp)}</small>
</div>
<div class="form-group">
<label for="oidcExtraScopes">${tf("oidc_extra_scopes_label", "Extra OIDC scopes")}:</label>
<input type="text" id="oidcExtraScopes" class="form-control"
placeholder="groups"
value="${escapeHTML(oidcExtraScopesValue || "")}"
${oidcExtraScopesLocked ? "disabled data-locked='1'" : ""} />
<small class="text-muted">${escapeHTML(oidcExtraScopesHelp)}</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>
`;
const loginOptsHost = document.getElementById("loginOptionsContent");
if (loginOptsHost) {
loginOptsHost.insertAdjacentHTML('beforeend', oidcHtml);
wireReplaceButtons(loginOptsHost);
wireOidcTestButton(loginOptsHost);
wireOidcDebugSnapshotButton(loginOptsHost);
}
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) {
if (!proPrimaryAdmin) {
proContent.innerHTML = `
<div class="card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:720px; margin:8px auto;">
<strong>FileRise Pro</strong>
<div class="text-muted" style="margin-top:6px;">
This is only viewable on the registered administrator.
</div>
</div>
`;
} else {
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
const norm = (v) => (String(v || '').trim().replace(/^v/i, ''));
const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : '';
const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || '';
const hasCurrent = !!norm(currentVersionRaw);
const hasLatest = !!norm(latestVersionRaw);
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
const hasSavedLicense = !!(proLicense && String(proLicense).trim().startsWith('FRP1.'));
const showRenewLinks = isPro || hasSavedLicense;
const showInstallOptions = isPro || hasSavedLicense;
const showUpdateBadge = hasUpdate && !updatesExpired;
const autoUpdateAllowed = hasSavedLicense && !updatesExpired;
const autoUpdateBlockedMessage = hasSavedLicense
? 'Updates expired. Renew to download newer Pro bundles.'
: 'Save a license key to enable automatic download.';
const updatesExpiredNotice = updatesExpired && proUpdatesUntil
? `<div class="small text-warning" style="margin-top:6px; margin-bottom:8px;">
Updates expired on ${escapeHTML(proUpdatesUntil)}. Downloading or installing a newer Pro bundle will deactivate Pro. Renew to update.
</div>`
: '';
// 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 updatesLabel = '';
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
const mj = proMaxMajor || 1;
updatesLabel = `Updates: lifetime for FileRise Pro ${mj}.x`;
} else if (proUpdatesUntil) {
updatesLabel = updatesExpired
? `Updates expired on ${proUpdatesUntil} (Pro stays active)`
: `Updates until ${proUpdatesUntil}`;
}
const proMetaHtml =
isPro && (proType || proEmail || proVersion || planLabel || updatesLabel)
? `
<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>` : ''}
${updatesLabel ? `
<div>
${updatesLabel}
</div>` : ''}
${hasCurrent ? `
<div>
Installed Pro bundle: v${norm(currentVersionRaw)}
</div>` : ''}
${hasLatest ? `
<div>
Latest Pro bundle (UI hint): ${latestVersionRaw}
</div>` : ''}
</div>
`
: '';
const needsCompatWarning = isPro
&& proApiLevel > 0
&& proApiLevel < CORE_REQUIRED_PRO_API_LEVEL;
const compatHtml = needsCompatWarning
? `
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#b45309;">
<div><strong>Compatibility warning:</strong> Core features require Pro API level ${CORE_REQUIRED_PRO_API_LEVEL} (v${PRO_API_MIN_VERSION_LABELS.sources}+). Installed Pro API level: ${proApiLevel}. Those features will stay disabled until you update the Pro bundle.</div>
${updatesExpired ? `<div>Updates expired on ${escapeHTML(proUpdatesUntil)}. Renew to download newer Pro bundles.</div>` : ''}
</div>
`
: '';
const instanceHtml = proInstanceId
? `
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
<div class="d-flex align-items-center flex-wrap" style="gap:6px;">
<span>Instance ID (required for 12-month updates plans): <code>${proInstanceId}</code></span>
<button type="button" class="btn btn-link btn-sm p-0" id="proCopyInstanceIdBtn">Copy</button>
</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}
${compatHtml}
${instanceHtml}
</div>
${!isPro ? `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/"
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.
You will need your Instance ID during checkout.
</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>
${showRenewLinks ? `
<div style="margin-top:10px;">
<div class="d-flex flex-wrap align-items-center" style="gap:8px;">
<a
href="https://filerise.net/pro/renew.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-secondary">
Renew 12-month updates
</a>
<a
href="https://filerise.net/pro/instances.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-secondary">
Manage instance IDs
</a>
</div>
<small class="text-muted d-block" style="margin-top:4px;">
Renewals extend updates for 12-month plans. Instance IDs apply to Business 12-month updates.
</small>
</div>
` : ''}
${showInstallOptions ? `
<div class="mt-3 border-top pt-3" style="margin-top:14px;">
<h6 class="mb-1">Install / update Pro bundle</h6>
<div class="text-muted small" style="margin-bottom:8px;">
Choose a method. Manual uploads never contact external services.
</div>
${updatesExpiredNotice}
<div class="text-muted small" style="font-weight:600;">Manual upload</div>
<div class="text-muted small" style="margin-bottom:6px;">
Download the ZIP from <a href="https://filerise.net/pro/update.php" target="_blank" rel="noopener noreferrer">filerise.net</a> and upload it here.
</div>
<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 class="text-muted small" style="font-weight:600; margin-top:10px;">One-click download</div>
<div class="text-muted small" style="margin-bottom:6px;">
Uses your saved license key and requires outbound access to filerise.net.
</div>
<div class="d-flex flex-wrap align-items-center gap-2" style="margin-top:4px;">
<button type="button"
id="btnDownloadProBundle"
class="btn btn-sm btn-secondary"
${autoUpdateAllowed ? '' : 'disabled'}>
Download + install latest
</button>
${showUpdateBadge ? `
<span class="badge badge-light">Update available</span>
` : ''}
</div>
<div id="proBundleStatus" class="small mt-2"></div>
</div>
` : `
<div class="mt-3 border-top pt-3" style="margin-top:14px;">
<h6 class="mb-1">Install / update Pro bundle</h6>
<div class="text-muted small">
Save a license key to unlock manual upload and one-click install options.
</div>
</div>
`}
</div>
`;
const updatePill = document.getElementById('proUpdatePill');
if (updatePill && !updatePill.__wired && !updatesExpired) {
updatePill.__wired = true;
updatePill.addEventListener('click', async (e) => {
e.preventDefault();
const choice = await showProUpdateChoiceModal({ hasAuto: autoUpdateAllowed });
if (choice === 'manual') {
window.open('https://filerise.net/pro/update.php', '_blank', 'noopener');
return;
}
if (choice === 'auto') {
const dlBtn = document.getElementById('btnDownloadProBundle');
if (dlBtn && !dlBtn.disabled) {
dlBtn.click();
} else {
showToast(autoUpdateBlockedMessage, 'error');
}
}
});
}
// Wire up local Pro bundle installer (upload .zip into core)
initProBundleInstaller({ updatesExpired, updatesUntil: proUpdatesUntil });
// 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(t('admin_license_file_invalid'));
return;
}
licenseTextarea.value = license;
showToast(t('admin_license_loaded_prompt'));
};
reader.onerror = () => {
showToast(t('admin_license_read_error'));
};
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(t('admin_license_copied'));
} catch (e) {
showToast(t('admin_license_copy_failed'));
}
});
}
const proCopyInstanceBtn = document.getElementById('proCopyInstanceIdBtn');
if (proCopyInstanceBtn && proInstanceId) {
proCopyInstanceBtn.addEventListener('click', async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(proInstanceId);
} else {
const ta = document.createElement('textarea');
ta.value = proInstanceId;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
showToast(t('admin_instance_id_copied'));
} catch (e) {
showToast(t('admin_instance_id_copy_failed'));
}
});
}
// Save license handler
const proSaveBtn = document.getElementById('proSaveLicenseBtn');
if (proSaveBtn) {
proSaveBtn.addEventListener('click', async () => {
const ta = document.getElementById('proLicenseInput');
const license = (ta && ta.value.trim()) || '';
const statusEl = document.getElementById('proBundleStatus');
const setStatus = (msg, tone = 'muted') => {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.className = `small text-${tone}`;
};
try {
proSaveBtn.disabled = true;
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 || t('admin_license_save_error'));
return;
}
showToast(t('admin_license_saved'));
if (!isPro) {
const ok = await showCustomConfirmModal(
'Download and install the latest Pro bundle now?'
);
if (ok) {
setStatus('Downloading and installing latest Pro bundle...', 'muted');
const progress = startProBundleProgress({
action: 'Updating Pro',
title: 'Downloading and installing FileRise Pro bundle'
});
try {
const resp = await fetch('/api/admin/downloadProBundle.php', {
method: 'POST',
headers: {
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
},
credentials: 'include'
});
let dlData = null;
try {
dlData = await resp.json();
} catch (_) {
dlData = null;
}
if (!resp.ok || !dlData || !dlData.success) {
const msg = dlData && (dlData.error || dlData.message)
? (dlData.error || dlData.message)
: `HTTP ${resp.status}`;
setStatus('Download/install failed: ' + msg, 'danger');
showToast(t('admin_pro_install_failed_detail', { error: msg }), 'error');
finishProBundleProgress(progress, false, msg);
return;
}
const finalVersion = dlData.proVersion ? String(dlData.proVersion) : '';
const versionText = finalVersion ? ` (${finalVersion})` : '';
setStatus('Pro bundle installed' + versionText + '. Reloading...', 'success');
showToast(t('admin_pro_installed_reloading', { version: versionText }));
finishProBundleProgress(progress, true);
if (typeof loadAdminConfigFunc === 'function') {
loadAdminConfigFunc();
}
setTimeout(() => {
window.location.reload();
}, 800);
return;
} catch (e) {
const errMsg = e && e.message ? e.message : 'Download/install failed.';
setStatus('Download/install failed.', 'danger');
showToast(t('admin_pro_install_failed'), 'error');
finishProBundleProgress(progress, false, errMsg);
return;
}
}
}
window.location.reload();
} catch (e) {
console.error(e);
showToast(t('admin_license_save_error'));
} finally {
proSaveBtn.disabled = false;
}
});
}
}
}
// --- end FileRise Pro section ---
// Pro features (Search Everywhere + Audit Logs)
const proFeaturesContainer = document.getElementById('proFeaturesContent');
const proFeaturesHeaderEl = document.getElementById('proFeaturesHeader');
if (proFeaturesHeaderEl) {
const iconHtml = '<i class="material-icons">expand_more</i>';
const pill = (!isPro || !proSearchApiOk || !proAuditAvailable)
? '<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span>'
: '';
const labelHtml = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
const inner = proFeaturesHeaderEl.querySelector('.section-header-inner');
if (inner) {
inner.innerHTML = labelHtml;
} else {
proFeaturesHeaderEl.innerHTML = `<div class="section-header-inner">${labelHtml}</div>`;
}
}
if (proFeaturesContainer) {
const proSearchBlockedReason = !isPro ? 'pro' : (!proSearchApiOk ? 'api' : null);
const needsUpgradeText = (!isPro)
? 'Requires an active FileRise Pro license.'
: (!proSearchApiOk ? `Requires FileRise Pro v${PRO_API_MIN_VERSION_LABELS.search}+.` : '');
const proSearchHtml = `
<div class="card" style="border:1px solid ${dark ? '#3a3a3a' : '#eaeaea'}; border-radius:10px; padding:12px; background:${dark ? '#1f1f1f' : '#fdfdfd'}; position:relative; margin-bottom:10px;">
<div class="d-flex align-items-center" style="gap:8px; margin-bottom:6px;">
<i class="material-icons" aria-hidden="true">travel_explore</i>
<div>
<div style="font-weight:600;">Search Everywhere</div>
<div class="text-muted" style="font-size:12px;">Global, ACL-aware search across all folders.</div>
</div>
</div>
<div class="form-check fr-toggle" style="margin-bottom:10px;">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="proSearchEnabled"
${proSearchEnabled ? 'checked' : ''}
${proSearchLocked ? 'data-locked=\"1\"' : ''}
${(proSearchBlockedReason) ? `disabled data-disabled-reason=\"${proSearchBlockedReason}\"` : ''} />
<label class="form-check-label" for="proSearchEnabled">
Enable Search Everywhere
</label>
${proSearchLocked ? `<div class="small text-warning" style="margin-top:4px;">Locked by FR_PRO_SEARCH_ENABLED env override.</div>` : ''}
${needsUpgradeText ? `<div class="small text-warning" style="margin-top:4px;">${needsUpgradeText}</div>` : ''}
</div>
<div class="form-group" style="margin-bottom:4px;">
<label for="proSearchLimit">Default result limit (max 200)</label>
<input type="number"
class="form-control"
id="proSearchLimit"
min="1"
max="200"
value="${proSearchDefaultLimit}"
${(proSearchBlockedReason || !proSearchEnabled || proSearchLocked) ? 'disabled' : ''} />
<small class="text-muted">Used when launching Search Everywhere; per-request limit is still capped at 200.</small>
</div>
${(!isPro || !proSearchApiOk) ? `
<div class="alert alert-warning" style="margin-top:8px; font-size:0.9rem; padding:8px 10px; border-radius:8px;">
${!isPro
? 'This feature is part of FileRise Pro. Purchase or activate a license to enable it.'
: ('Please upgrade your FileRise Pro bundle to v' + PRO_API_MIN_VERSION_LABELS.search + ' or newer to use Search Everywhere.')}
</div>
` : ''}
</div>
`;
const auditBlockedReason = !isPro ? 'pro' : (!proAuditAvailable ? 'upgrade' : null);
const auditHelpText = (!isPro)
? 'Requires an active FileRise Pro license.'
: (!proAuditAvailable ? `Upgrade FileRise Pro to v${PRO_API_MIN_VERSION_LABELS.audit}+ to enable Audit Logs.` : '');
const auditHtml = `
<div class="card" style="border:1px solid ${dark ? '#3a3a3a' : '#eaeaea'}; border-radius:10px; padding:12px; background:${dark ? '#1f1f1f' : '#fdfdfd'}; position:relative; margin-bottom:10px;">
<div class="d-flex align-items-center" style="gap:8px; margin-bottom:6px;">
<i class="material-icons" aria-hidden="true">fact_check</i>
<div>
<div style="font-weight:600;">Audit logging</div>
<div class="text-muted" style="font-size:12px;">Who did what, when, and where. Stored in FR_PRO_BUNDLE_DIR/audit/</div>
</div>
</div>
<div class="form-check fr-toggle" style="margin-bottom:10px;">
<input type="checkbox"
class="form-check-input fr-toggle-input"
id="proAuditEnabled"
${proAuditEnabled ? 'checked' : ''}
${(auditBlockedReason) ? `disabled data-disabled-reason="${auditBlockedReason}"` : ''} />
<label class="form-check-label" for="proAuditEnabled">
Enable audit logs
</label>
${auditHelpText ? `<div class="small text-warning" style="margin-top:4px;">${auditHelpText}</div>` : ''}
</div>
<div class="form-group" style="margin-bottom:8px;">
<label for="proAuditLevel">Logging level</label>
<select id="proAuditLevel" class="form-control" ${(auditBlockedReason || !proAuditEnabled) ? 'disabled' : ''}>
<option value="standard" ${proAuditLevel === 'standard' ? 'selected' : ''}>Standard (uploads, edits, renames, deletes)</option>
<option value="verbose" ${proAuditLevel === 'verbose' ? 'selected' : ''}>Verbose (includes downloads)</option>
</select>
</div>
<div class="form-row" style="display:flex; gap:10px; flex-wrap:wrap;">
<div class="form-group" style="flex:1; min-width:140px;">
<label for="proAuditMaxFileMb">Rotate at (MB)</label>
<input type="number" class="form-control" id="proAuditMaxFileMb" min="10" max="1024"
value="${proAuditMaxFileMb}"
${(auditBlockedReason || !proAuditEnabled) ? 'disabled' : ''} />
</div>
<div class="form-group" style="flex:1; min-width:140px;">
<label for="proAuditMaxFiles">Max log files</label>
<input type="number" class="form-control" id="proAuditMaxFiles" min="1" max="10"
value="${proAuditMaxFiles}"
${(auditBlockedReason || !proAuditEnabled) ? 'disabled' : ''} />
</div>
</div>
<small class="text-muted">Rotation keeps the newest file plus up to ${proAuditMaxFiles - 1} archives.</small>
</div>
<div class="card" style="border:1px solid ${dark ? '#3a3a3a' : '#eaeaea'}; border-radius:10px; padding:12px; background:${dark ? '#1f1f1f' : '#fdfdfd'}; position:relative;">
<div class="d-flex align-items-center" style="gap:8px; margin-bottom:8px;">
<i class="material-icons" aria-hidden="true">history</i>
<div>
<div style="font-weight:600;">Activity history</div>
<div class="text-muted" style="font-size:12px;">Filter and export audit events.</div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center; margin:6px 0 8px;">
<button type="button" id="auditFiltersToggle" class="btn btn-light btn-sm">Show filters</button>
<div class="text-muted" style="font-size:12px;">User / action / source / storage / folder / dates</div>
</div>
<div id="auditFiltersWrap" style="display:none; gap:8px; flex-wrap:wrap; margin-bottom:8px;">
<input id="auditFilterUser" class="form-control" style="min-width:140px;" placeholder="User" />
<input id="auditFilterAction" class="form-control" style="min-width:140px;" placeholder="Action" />
<input id="auditFilterSource" class="form-control" style="min-width:120px;" placeholder="Source" />
<input id="auditFilterStorage" class="form-control" style="min-width:140px;" placeholder="Storage" />
<input id="auditFilterFolder" class="form-control" style="min-width:160px;" placeholder="Folder" />
<input id="auditFilterFrom" class="form-control" style="min-width:140px;" type="date" />
<input id="auditFilterTo" class="form-control" style="min-width:140px;" type="date" />
<input id="auditFilterLimit" class="form-control" style="min-width:120px;" type="number" min="10" max="500" value="200" placeholder="Limit" />
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:6px;">
<button type="button" id="auditRefreshBtn" class="btn btn-secondary btn-sm">Refresh</button>
<button type="button" id="auditExportBtn" class="btn btn-primary btn-sm">Download CSV</button>
</div>
<div id="auditStatus" class="text-muted" style="font-size:12px; margin-bottom:8px;"></div>
<div class="table-responsive audit-table-wrap">
<table class="table table-sm" style="margin-bottom:0;">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Source</th>
<th>Storage</th>
<th>Folder</th>
<th>Path</th>
<th>From</th>
<th>To</th>
<th>IP</th>
<th>User Agent</th>
<th>Meta</th>
</tr>
</thead>
<tbody id="auditTableBody"></tbody>
</table>
</div>
</div>
`;
proFeaturesContainer.innerHTML = proSearchHtml + auditHtml;
const proSearchToggle = document.getElementById('proSearchEnabled');
const proSearchLimit = document.getElementById('proSearchLimit');
const syncProSearchLimit = () => {
if (!proSearchLimit || !proSearchToggle) return;
const locked = proSearchToggle.dataset.locked === '1' || !isPro || !proSearchApiOk;
const enabled = !!proSearchToggle.checked;
proSearchLimit.disabled = locked || !enabled;
};
if (proSearchToggle && !proSearchToggle.__wired) {
proSearchToggle.__wired = true;
proSearchToggle.addEventListener('change', syncProSearchLimit);
}
syncProSearchLimit();
const auditEnabledEl = document.getElementById('proAuditEnabled');
const auditLevelEl = document.getElementById('proAuditLevel');
const auditMaxFileMbEl = document.getElementById('proAuditMaxFileMb');
const auditMaxFilesEl = document.getElementById('proAuditMaxFiles');
const syncAuditConfigFields = () => {
if (!auditEnabledEl) return;
const locked = auditBlockedReason || !isPro || !proAuditAvailable;
const enabled = !!auditEnabledEl.checked;
if (auditLevelEl) auditLevelEl.disabled = !!locked || !enabled;
if (auditMaxFileMbEl) auditMaxFileMbEl.disabled = !!locked || !enabled;
if (auditMaxFilesEl) auditMaxFilesEl.disabled = !!locked || !enabled;
};
if (auditEnabledEl && !auditEnabledEl.__wired) {
auditEnabledEl.__wired = true;
auditEnabledEl.addEventListener('change', syncAuditConfigFields);
}
syncAuditConfigFields();
const auditStatusEl = document.getElementById('auditStatus');
const auditTableBody = document.getElementById('auditTableBody');
const auditFiltersWrap = document.getElementById('auditFiltersWrap');
const auditFiltersToggle = document.getElementById('auditFiltersToggle');
if (auditFiltersWrap && auditFiltersToggle && !auditFiltersToggle.__wired) {
auditFiltersToggle.__wired = true;
let isOpen = false;
try {
isOpen = localStorage.getItem('auditFiltersOpen') === '1';
} catch (e) { }
const setOpen = (open) => {
auditFiltersWrap.style.display = open ? 'flex' : 'none';
auditFiltersToggle.textContent = open ? 'Hide filters' : 'Show filters';
};
setOpen(isOpen);
auditFiltersToggle.addEventListener('click', () => {
isOpen = !isOpen;
try { localStorage.setItem('auditFiltersOpen', isOpen ? '1' : '0'); } catch (e) { }
setOpen(isOpen);
});
} else if (auditFiltersWrap) {
auditFiltersWrap.style.display = 'none';
}
const auditFilters = () => {
const params = new URLSearchParams();
const add = (k, v) => { if (v) params.set(k, v); };
add('user', (document.getElementById('auditFilterUser')?.value || '').trim());
add('action', (document.getElementById('auditFilterAction')?.value || '').trim());
add('source', (document.getElementById('auditFilterSource')?.value || '').trim());
add('storage', (document.getElementById('auditFilterStorage')?.value || '').trim());
add('folder', (document.getElementById('auditFilterFolder')?.value || '').trim());
add('from', (document.getElementById('auditFilterFrom')?.value || '').trim());
add('to', (document.getElementById('auditFilterTo')?.value || '').trim());
const lim = parseInt((document.getElementById('auditFilterLimit')?.value || '200'), 10);
if (lim > 0) params.set('limit', String(Math.min(500, lim)));
return params;
};
const renderAuditRows = (rows) => {
if (!auditTableBody) return;
auditTableBody.textContent = '';
if (!rows || !rows.length) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 12;
td.className = 'text-muted';
td.textContent = 'No audit entries found for this filter.';
tr.appendChild(td);
auditTableBody.appendChild(tr);
return;
}
rows.forEach(row => {
const tr = document.createElement('tr');
const storageName = row.storageName || '';
const storageId = row.storageId || '';
let storageLabel = storageName || storageId || '';
if (storageName && storageId && storageName !== storageId) {
storageLabel = `${storageName} (${storageId})`;
}
const cols = [
row.ts || '',
row.user || '',
row.action || '',
row.source || '',
storageLabel,
row.folder || '',
row.path || '',
row.from || '',
row.to || '',
row.ip || '',
row.ua || '',
row.meta || ''
];
cols.forEach((val, idx) => {
const td = document.createElement('td');
let text = val;
if (idx === 11 && val && typeof val === 'object') {
try { text = JSON.stringify(val); } catch (e) { text = ''; }
}
if (idx === 10 && typeof text === 'string' && text.length > 30) {
td.title = text;
text = text.slice(0, 30) + '...';
}
if (idx === 11 && typeof text === 'string' && text.length > 160) {
td.title = text;
text = text.slice(0, 160) + '...';
}
td.textContent = (text == null ? '' : String(text));
tr.appendChild(td);
});
auditTableBody.appendChild(tr);
});
};
const loadAuditLogs = async () => {
if (!auditStatusEl) return;
if (!isPro || !proAuditAvailable) {
auditStatusEl.textContent = auditHelpText || 'Audit Logs are not available.';
return;
}
auditStatusEl.textContent = 'Loading audit logs...';
if (auditTableBody) auditTableBody.textContent = '';
try {
const params = auditFilters();
const url = withBase('/api/pro/audit/list.php?' + params.toString());
const res = await fetch(url, { credentials: 'include' });
const data = await safeJson(res);
const rows = data && Array.isArray(data.rows) ? data.rows : [];
renderAuditRows(rows);
auditStatusEl.textContent = data && data.truncated
? 'Showing latest results (truncated).'
: 'Loaded ' + rows.length + ' entries.';
} catch (e) {
console.error('Audit log load error', e);
auditStatusEl.textContent = (e && e.message) ? e.message : 'Failed to load audit logs.';
renderAuditRows([]);
}
};
const refreshBtn = document.getElementById('auditRefreshBtn');
if (refreshBtn && !refreshBtn.__wired) {
refreshBtn.__wired = true;
refreshBtn.addEventListener('click', loadAuditLogs);
}
const exportBtn = document.getElementById('auditExportBtn');
if (exportBtn && !exportBtn.__wired) {
exportBtn.__wired = true;
exportBtn.addEventListener('click', () => {
if (!isPro || !proAuditAvailable) {
showToast(t('admin_audit_logs_unavailable'));
return;
}
const params = auditFilters();
const url = withBase('/api/pro/audit/exportCsv.php?' + params.toString());
window.location.href = url;
});
}
// Initial load for admins if available
if (isPro && proAuditAvailable) {
loadAuditLogs();
}
// Ensure header toggle works even if the core listener missed it
const pfHeader = document.getElementById('proFeaturesHeader');
if (pfHeader && !pfHeader.__wired) {
pfHeader.__wired = true;
pfHeader.addEventListener('click', () => toggleSection('proFeatures'));
}
}
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 || "";
const uploadCfg = (config.uploads && typeof config.uploads === "object") ? config.uploads : {};
const chunkEl = document.getElementById("resumableChunkMb");
if (chunkEl) {
const raw = uploadCfg.resumableChunkMb;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
chunkEl.value = val;
}
const ttlEl = document.getElementById("resumableTtlHours");
if (ttlEl) {
const raw = uploadCfg.resumableTtlHours;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(168, Math.max(0.5, num)) : 6;
ttlEl.value = val;
}
// Published URL (optional)
const deploy = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
const pubEl = document.getElementById("publishedUrl");
if (pubEl) {
pubEl.value = (deploy.publishedUrl || "").toString();
if (deploy.publishedUrlLockedByEnv) {
pubEl.disabled = true;
pubEl.dataset.locked = "1";
pubEl.value = (deploy.publishedUrlEffective || deploy.publishedUrl || "").toString();
} else {
pubEl.disabled = false;
pubEl.dataset.locked = "0";
}
}
// FFmpeg path (optional)
const ffmpegEl = document.getElementById("ffmpegPath");
if (ffmpegEl) {
const locked = !!config.ffmpegPathLockedByEnv;
const val = locked
? (config.ffmpegPathEffective || config.ffmpegPath || "")
: (config.ffmpegPath || "");
ffmpegEl.value = (val || "").toString();
ffmpegEl.disabled = locked;
ffmpegEl.dataset.locked = locked ? "1" : "0";
const help = document.getElementById("ffmpegPathHelp");
if (help) {
help.textContent = locked ? ffmpegHelpLocked : ffmpegHelpDefault;
}
}
// --- 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.';
}
}
}
const clamExclude = document.getElementById("clamavExcludeDirs");
if (clamExclude) {
clamExclude.value = (cfgClam.excludeDirs || "").toString();
if (cfgClam.excludeLockedByEnv) {
clamExclude.disabled = true;
const help = document.getElementById("clamavExcludeDirsHelp");
if (help) {
help.textContent =
'Controlled by container env VIRUS_SCAN_EXCLUDE_DIRS. 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 || "";
const uploadCfg2 = (config.uploads && typeof config.uploads === "object") ? config.uploads : {};
const chunkEl2 = document.getElementById("resumableChunkMb");
if (chunkEl2) {
const raw = uploadCfg2.resumableChunkMb;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
chunkEl2.value = val;
}
const ttlEl2 = document.getElementById("resumableTtlHours");
if (ttlEl2) {
const raw = uploadCfg2.resumableTtlHours;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(168, Math.max(0.5, num)) : 6;
ttlEl2.value = val;
}
// Published URL (optional)
const deploy2 = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
const pubEl2 = document.getElementById("publishedUrl");
if (pubEl2) {
pubEl2.value = (deploy2.publishedUrl || "").toString();
if (deploy2.publishedUrlLockedByEnv) {
pubEl2.disabled = true;
pubEl2.dataset.locked = "1";
pubEl2.value = (deploy2.publishedUrlEffective || deploy2.publishedUrl || "").toString();
} else {
pubEl2.disabled = false;
pubEl2.dataset.locked = "0";
}
}
// FFmpeg path (optional)
const ffmpegEl2 = document.getElementById("ffmpegPath");
if (ffmpegEl2) {
const locked = !!config.ffmpegPathLockedByEnv;
const val = locked
? (config.ffmpegPathEffective || config.ffmpegPath || "")
: (config.ffmpegPath || "");
ffmpegEl2.value = (val || "").toString();
ffmpegEl2.disabled = locked;
ffmpegEl2.dataset.locked = locked ? "1" : "0";
const help = document.getElementById("ffmpegPathHelp");
if (help) {
help.textContent = locked ? ffmpegHelpLocked : ffmpegHelpDefault;
}
}
const ignoreEl = document.getElementById("ignoreRegex");
if (ignoreEl) {
const locked = !!config.ignoreRegexLockedByEnv;
const val = locked
? (config.ignoreRegexEffective || "")
: (config.ignoreRegex || "");
ignoreEl.value = (val || "").toString();
ignoreEl.disabled = locked;
ignoreEl.dataset.locked = locked ? "1" : "0";
}
// --- 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.';
}
}
}
const clamExclude = document.getElementById("clamavExcludeDirs");
if (clamExclude) {
clamExclude.value = (cfgClam.excludeDirs || "").toString();
clamExclude.disabled = false;
const help = document.getElementById("clamavExcludeDirsHelp");
if (help) {
help.textContent =
'Comma or newline separated paths relative to the source root (example: snapshot, tmp). For Pro sources you can prefix with source id (example: s3:/snapshot).';
}
if (cfgClam.excludeLockedByEnv) {
clamExclude.disabled = true;
if (help) {
help.textContent =
'Controlled by container env VIRUS_SCAN_EXCLUDE_DIRS. Change it in your Docker/host env.';
}
}
}
const uploadScope = document.getElementById("uploadContent");
const headerSettingsScope = document.getElementById("headerSettingsContent");
wireIgnoreRegexPresetButton(headerSettingsScope);
wireClamavTestButton(uploadScope);
wireResumableCleanupButton(uploadScope);
initVirusLogUI({ isPro });
renderAdminEncryptionSection({ config, dark });
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
const publicClientChk = document.getElementById("oidcPublicClient");
if (publicClientChk) {
publicClientChk.checked = !!window.currentOIDCConfig?.publicClient;
}
const idEl = document.getElementById("oidcClientId");
const secEl = document.getElementById("oidcClientSecret");
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || "";
const oidcScope = document.getElementById("oidcContent") || document.getElementById("loginOptionsContent");
if (oidcScope) {
wireReplaceButtons(oidcScope);
wireOidcTestButton(oidcScope);
}
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 || "";
const oidcGroupClaimEl = document.getElementById("oidcGroupClaim");
if (oidcGroupClaimEl) {
const locked = !!(config.oidc && config.oidc.groupClaimLockedByEnv);
const value = locked
? (config.oidc.groupClaimEffective || "")
: (config.oidc.groupClaim || "");
oidcGroupClaimEl.value = value;
if (locked) {
oidcGroupClaimEl.disabled = true;
oidcGroupClaimEl.dataset.locked = "1";
} else {
oidcGroupClaimEl.disabled = false;
oidcGroupClaimEl.removeAttribute("data-locked");
}
}
const oidcExtraScopesEl = document.getElementById("oidcExtraScopes");
if (oidcExtraScopesEl) {
const locked = !!(config.oidc && config.oidc.extraScopesLockedByEnv);
const value = locked
? (config.oidc.extraScopesEffective || "")
: (config.oidc.extraScopes || "");
oidcExtraScopesEl.value = value;
if (locked) {
oidcExtraScopesEl.disabled = true;
oidcExtraScopesEl.dataset.locked = "1";
} else {
oidcExtraScopesEl.disabled = false;
oidcExtraScopesEl.removeAttribute("data-locked");
}
}
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);
}
if (oidcScope) {
wireOidcDebugSnapshotButton(oidcScope);
}
// Refresh Pro features section when reopening
const pfHeader = document.getElementById('proFeaturesHeader');
if (pfHeader) {
const iconHtml = '<i class="material-icons">expand_more</i>';
const pill = (!isPro || !proSearchApiOk || !proAuditAvailable)
? '<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span>'
: '';
const labelHtml = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
const inner = pfHeader.querySelector('.section-header-inner');
if (inner) {
inner.innerHTML = labelHtml;
} else {
pfHeader.innerHTML = `<div class="section-header-inner">${labelHtml}</div>`;
}
}
const psToggle = document.getElementById("proSearchEnabled");
const psLimit = document.getElementById("proSearchLimit");
if (psToggle) {
psToggle.checked = proSearchEnabled;
if (proSearchLocked) {
psToggle.dataset.locked = "1";
} else {
psToggle.removeAttribute("data-locked");
}
}
if (psLimit) {
psLimit.value = proSearchDefaultLimit;
}
const syncPs = () => {
if (!psToggle || !psLimit) return;
const locked = psToggle.dataset.locked === "1" || !isPro || !proSearchApiOk;
psLimit.disabled = locked || !psToggle.checked;
};
if (psToggle && !psToggle.__wired) {
psToggle.__wired = true;
psToggle.addEventListener("change", syncPs);
}
syncPs();
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 Thanks / 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 oidcPublicClient = !!document.getElementById("oidcPublicClient")?.checked;
const authHeaderName =
(document.getElementById("authHeaderName")?.value || "").trim() ||
"X-Remote-User";
const payload = {
header_title: document.getElementById("headerTitle")?.value || "",
publishedUrl: (() => {
const el = document.getElementById("publishedUrl");
if (!el) return "";
if (el.dataset.locked === "1") return el.value || "";
return (el.value || "").trim();
})(),
ffmpegPath: (() => {
const el = document.getElementById("ffmpegPath");
if (!el) return "";
if (el.dataset.locked === "1") return el.value || "";
return (el.value || "").trim();
})(),
ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(),
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,
publicClient: oidcPublicClient,
groupClaim: (document.getElementById("oidcGroupClaim")?.value || "").trim(),
extraScopes: (document.getElementById("oidcExtraScopes")?.value || "").trim(),
// 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(),
metaDescription: (document.getElementById("brandingMetaDescription")?.value || "").trim(),
faviconSvg: (document.getElementById("brandingFaviconSvg")?.value || "").trim(),
faviconPng: (document.getElementById("brandingFaviconPng")?.value || "").trim(),
faviconIco: (document.getElementById("brandingFaviconIco")?.value || "").trim(),
appleTouchIcon: (document.getElementById("brandingAppleTouchIcon")?.value || "").trim(),
maskIcon: (document.getElementById("brandingMaskIcon")?.value || "").trim(),
maskIconColor: (document.getElementById("brandingMaskIconColor")?.value || "").trim(),
themeColorLight: (document.getElementById("brandingThemeColorLight")?.value || "").trim(),
themeColorDark: (document.getElementById("brandingThemeColorDark")?.value || "").trim(),
loginBgLight: (document.getElementById("brandingLoginBgLight")?.value || "").trim(),
loginBgDark: (document.getElementById("brandingLoginBgDark")?.value || "").trim(),
appBgLight: (document.getElementById("brandingAppBgLight")?.value || "").trim(),
appBgDark: (document.getElementById("brandingAppBgDark")?.value || "").trim(),
loginTagline: (document.getElementById("brandingLoginTagline")?.value || "").trim(),
footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
},
display: {
defaultLanguage: (document.getElementById("defaultLanguage")?.value || "en").trim(),
hoverPreviewMaxImageMb: Math.max(
1,
Math.min(
50,
parseInt(document.getElementById("hoverPreviewMaxImageMb")?.value || "8", 10) || 8
)
),
hoverPreviewMaxVideoMb: Math.max(
1,
Math.min(
2048,
parseInt(document.getElementById("hoverPreviewMaxVideoMb")?.value || "200", 10) || 200
)
),
fileListSummaryDepth: Math.max(
0,
Math.min(
10,
Number.isFinite(parseInt(document.getElementById("fileListSummaryDepth")?.value || "2", 10))
? parseInt(document.getElementById("fileListSummaryDepth")?.value || "2", 10)
: 2
)
),
},
uploads: {
resumableChunkMb: Math.max(
0.5,
Math.min(
100,
parseFloat(document.getElementById("resumableChunkMb")?.value || "1.5") || 1.5
)
),
resumableTtlHours: Math.max(
0.5,
Math.min(
168,
parseFloat(document.getElementById("resumableTtlHours")?.value || "6") || 6
)
),
},
clamav: {
scanUploads: document.getElementById("clamavScanUploads").checked,
excludeDirs: (document.getElementById("clamavExcludeDirs")?.value || "").trim(),
},
proSearch: {
enabled: !!document.getElementById("proSearchEnabled")?.checked,
defaultLimit: Math.max(
1,
Math.min(
200,
parseInt(document.getElementById("proSearchLimit")?.value || "50", 10) || 50
)
),
},
proAudit: {
enabled: !!document.getElementById("proAuditEnabled")?.checked,
level: (document.getElementById("proAuditLevel")?.value || "verbose").trim(),
maxFileMb: Math.max(
10,
parseInt(document.getElementById("proAuditMaxFileMb")?.value || "200", 10) || 200
),
maxFiles: Math.max(
1,
Math.min(
10,
parseInt(document.getElementById("proAuditMaxFiles")?.value || "10", 10) || 10
)
),
},
};
// --- 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 (oidcPublicClient) {
// Explicitly clear any stored secret when switching to public client mode
payload.oidc.clientSecret = '';
} else 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(t('error_prefix', { error: j.error })); return; }
showToast(t('admin_settings_saved'));
closeAdminPanel();
applyHeaderColorsFromAdmin();
updateHeaderLogoFromAdmin();
applyFooterFromAdmin();
})
.catch(() => showToast(t('admin_settings_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";
}
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 = isAdminUser(u);
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 = isAdminUser(user);
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, orderedFolders);
// 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>";
}
}