diff --git a/CHANGELOG.md b/CHANGELOG.md
index 690ea4a..3d17cf9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,62 @@
# Changelog
+## Changes 12/2/2025 (v2.3.0)
+
+release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export
+
+**v2.3.0 – Portal branding, intake presets & upload limits**
+
+**Client portals (Pro)**
+
+- Added **per-portal branding**:
+ - Custom accent color and footer text, applied to both the portal page and the login card.
+ - Optional **portal logo** stored under `uploads/profile_pics`, with a simple upload flow from the Client Portals modal.
+- Upgraded the **intake form**:
+ - Per-field labels, defaults, visibility, and "required" switches for Name, Email, Reference, and Notes.
+ - New presets for common workflows: **Legal intake**, **Tax client**, and **Order / RMA** that pre-fill labels and hints.
+- New **thank-you screen**:
+ - Optional “Thank you” message shown after successful uploads, configurable per portal.
+- New **upload rules per portal**:
+ - Max file size (MB) override.
+ - Allowed extensions whitelist (comma-separated).
+ - Simple per-browser daily upload limit, enforced in the portal UI with clear messaging.
+- Improved **portal description**:
+ - Portal page now shows active rules (max size, allowed types, daily limit) so clients know what’s allowed.
+- **Submissions block** in the Client Portals modal:
+ - Inline list of portal submissions with timestamps, folder, submitter and IP.
+ - “Load submissions” button with paging-style UI and improved styling in both light and dark mode.
+ - (New) **Export to CSV** action from the submissions block for easier reporting and audits.
+
+**Portal login**
+
+- Portal login screen now respects **per-portal branding**:
+ - Uses the portal’s logo (or falls back to the default FileRise logo).
+ - Reuses accent color and footer text from portal metadata so login matches the portal look.
+
+**Admin panel**
+
+- Added dedicated **Client Portals** editor section with:
+ - Portal slug / label, folder picker, expiry, upload/download options.
+ - Branding, logo upload, intake presets, upload limits, thank-you message, and live submissions preview.
+- Wired up new **ONLYOFFICE** admin section:
+ - Toggle, document server origin, JWT secret management, plus built-in connection tests and CSP helper.
+- Wired up **Sponsor** section helper with copy-to-clipboard convenience for support links.
+- Moved a bunch of admin-panel specific styles into `styles.css` for better maintainability (modal sizing, section headers, dark-mode tweaks).
+
+**File Preview**
+
+- Remember the user’s volume (and mute state) in localStorage and re-apply it for every video preview in browser.
+
+**Security / hardening**
+
+- New `public/api/pro/portals/uploadLogo.php` endpoint for portal logos:
+ - Pro-only, admin-only, CSRF-protected.
+ - Accepts JPEG/PNG/GIF up to 2MB and stores them under `UPLOAD_DIR/profile_pics` with randomised names.
+
+_No breaking changes expected; existing portals continue to work with default settings._
+
+---
+
## Changes 11/30/2025 (v2.2.4)
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
diff --git a/public/api/pro/portals/publicMeta.php b/public/api/pro/portals/publicMeta.php
index f228a59..d7117c4 100644
--- a/public/api/pro/portals/publicMeta.php
+++ b/public/api/pro/portals/publicMeta.php
@@ -100,6 +100,7 @@ $public = [
'introText' => (string)($portal['introText'] ?? ''),
'brandColor' => (string)($portal['brandColor'] ?? ''),
'footerText' => (string)($portal['footerText'] ?? ''),
+ 'logoFile' => (string)($portal['logoFile'] ?? ''),
];
echo json_encode([
diff --git a/public/api/pro/portals/uploadLogo.php b/public/api/pro/portals/uploadLogo.php
new file mode 100644
index 0000000..cc955bd
--- /dev/null
+++ b/public/api/pro/portals/uploadLogo.php
@@ -0,0 +1,30 @@
+ false,
+ 'error' => 'FileRise Pro is not active on this instance.'
+ ]);
+ exit;
+}
+
+try {
+ $ctrl = new UserController();
+ $ctrl->uploadPortalLogo();
+} catch (Throwable $e) {
+ http_response_code(500);
+ echo json_encode([
+ 'success' => false,
+ 'error' => 'Exception: ' . $e->getMessage(),
+ ]);
+}
\ No newline at end of file
diff --git a/public/css/styles.css b/public/css/styles.css
index 7d7997e..74bef69 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -2250,4 +2250,314 @@ body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn .material-icons{
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
+}
+
+ /* Modal sizing */
+ #adminPanelModal .modal-content {
+ max-width: 1100px;
+ width: 60% !important;
+ background: #fff !important;
+ color: #000 !important;
+ border: 1px solid #ccc !important;
+}
+@media (max-width: 900px) {
+ #adminPanelModal .modal-content {
+ width: 90% !important;
+ max-width: none !important;
+ }
+}
+.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
+.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
+.dark-mode .form-control::placeholder { color:#888; }
+
+.section-header {
+ background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
+ display:flex; align-items:center; justify-content:space-between; margin-top:16px;
+}
+.section-header:first-of-type { margin-top:0; }
+.section-header.collapsed .material-icons { transform:rotate(-90deg); }
+.section-header .material-icons { transition:transform .3s; color:#444; }
+.dark-mode .section-header { background:#3a3a3a; color:#eee; }
+.dark-mode .section-header .material-icons { color:#ccc; }
+
+.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
+
+#adminPanelModal .editor-close-btn {
+ position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
+ font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%;
+ text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
+ border:2px solid transparent; transition:all .3s;
+}
+#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
+.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
+
+.action-row { display:flex; justify-content:space-between; margin-top:15px; }
+
+/* ---------- Folder access editor ---------- */
+.folder-access-toolbar {
+ display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
+}
+.folder-access-list {
+ --col-perm: 84px;
+ --col-folder-min: 340px;
+ max-height: 320px;
+ overflow: auto;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ padding: 0;
+}
+.dark-mode .folder-access-list { border-color:#555; }
+
+.folder-access-header,
+.folder-access-row {
+ display: grid;
+ grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(14, var(--col-perm));
+ gap: 8px;
+ align-items: center;
+ padding: 8px 10px;
+}
+.folder-access-header {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: #fff;
+ font-weight: 700;
+ border-bottom: 1px solid rgba(0,0,0,0.12);
+}
+.dark-mode .folder-access-header { background:#2c2c2c; }
+
+.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
+.folder-access-row:last-child { border-bottom: none; }
+
+.perm-col { text-align:center; white-space:nowrap; }
+.folder-access-header > div { white-space: nowrap; }
+
+.folder-badge {
+ display:inline-flex; align-items:center; gap:6px;
+ font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
+ min-width: 0;
+}
+
+.muted { opacity:.65; font-size:.9em; }
+
+/* Inheritance visuals */
+.inherited-row {
+ opacity: 0.8;
+ background: rgba(32, 132, 255, 0.06);
+}
+.inherited-tag {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 10px;
+ background: rgba(32,132,255,0.12);
+ color: #2064ff;
+ margin-left: 6px;
+}
+.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
+.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
+
+@media (max-width: 900px) {
+ .folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
+}
+
+/* Folder cell: horizontal-only scroll */
+.folder-cell{
+overflow-x:auto;
+overflow-y:hidden;
+white-space:nowrap;
+-webkit-overflow-scrolling:touch;
+}
+/* nicer thin scrollbar (supported browsers) */
+.folder-cell::-webkit-scrollbar{ height:8px; }
+.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
+.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
+
+/* Badge now doesn't clip; let the wrapper handle scroll */
+.folder-badge{
+display:inline-flex; align-items:center; gap:6px;
+font-weight:600;
+min-width:0; /* allow child to be as wide as needed inside scroller */
+}
+ .group-members-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.group-member-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ border-radius: 999px;
+ font-size: 11px;
+ background-color: #1e88e5;
+ color: #fff;
+}
+
+.dark-mode .group-member-pill {
+ background-color: #1565c0;
+ color: #fff;
+}
+ /* Client portal cards */
+#clientPortalsBody .portal-card {
+ position: relative;
+ border-radius: 12px;
+ border: 1px solid #ddd;
+ padding: 10px 12px 8px;
+ margin-bottom: 10px;
+}
+.dark-mode #clientPortalsBody .portal-card {
+ border-color: #555;
+ background: #1f1f1f;
+}
+
+.portal-card-header {
+ display:flex;
+ align-items:center;
+ gap:8px;
+ cursor:pointer;
+ padding:4px 4px 4px 0;
+}
+.portal-card-header .portal-card-caret {
+ display:inline-block;
+ font-size:14px;
+ transform:rotate(-90deg);
+ transition:transform .15s ease;
+}
+.portal-card-header[aria-expanded="true"] .portal-card-caret {
+ transform:rotate(0deg);
+}
+.portal-card-header-main {
+ display:flex;
+ flex-wrap:wrap;
+ gap:6px;
+ align-items:baseline;
+}
+.portal-card-header-main strong {
+ font-size:.9rem;
+}
+.portal-card-header-main .portal-card-slug {
+ font-family:monospace;
+ font-size:.8rem;
+ opacity:.75;
+}
+
+.portal-card-delete,
+.group-card-delete {
+ position:absolute;
+ top:10px;
+ right:6px;
+ width:30px;
+ height:30px;
+ border-radius:50%;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ padding:0;
+}
+.group-card-delete {
+
+ top:4px;
+
+}
+
+.portal-card-body {
+ margin-top:6px;
+}
+
+#clientPortalsBody .portal-meta-row {
+ display:flex;
+ flex-wrap:wrap;
+ gap:8px;
+ align-items:center;
+ margin-top:6px;
+}
+#clientPortalsBody .portal-meta-row label {
+ margin:0;
+ font-size:.8rem;
+}
+
+/* Make date input look consistent */
+#clientPortalsBody input[type="date"].form-control-sm {
+ border-radius:.25rem;
+}
+ /* -------- Client portals: Expires alignment + date styling -------- */
+#clientPortalsBody .portal-expires-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+#clientPortalsBody .portal-expires-group label {
+ margin: 0;
+ font-size: 0.85rem;
+}
+#clientPortalsBody .portal-expiry-input {
+ max-width: 170px;
+ border-radius: 6px;
+}
+.dark-mode #clientPortalsBody .portal-expiry-input {
+ background-color: #333;
+ border-color: #555;
+ color: #eee;
+}
+
+ #clientPortalsBody .portal-submissions-block {
+ margin-top: 8px;
+ padding-top: 6px;
+ border-top: 1px dashed rgba(0,0,0,0.1);
+}
+#clientPortalsBody .portal-submissions-list {
+ max-height: 180px;
+ overflow: auto;
+ margin-top: 4px;
+ padding: 4px;
+ border-radius: 6px;
+ border: 1px solid rgba(0,0,0,0.08);
+ background: rgba(0,0,0,0.02);
+ font-size: 0.8rem;
+}
+.dark-mode #clientPortalsBody .portal-submissions-list {
+ border-color: #555;
+ background: rgba(255,255,255,0.02);
+}
+#clientPortalsBody .portal-submissions-item {
+ padding: 4px 2px;
+ border-bottom: 1px solid rgba(0,0,0,0.05);
+}
+#clientPortalsBody .portal-submissions-item:last-child {
+ border-bottom: none;
+}
+#clientPortalsBody .portal-submissions-meta {
+ opacity: 0.75;
+ font-size: 0.75rem;
+}
+
+/* Client portal submissions load button */
+.portal-submissions-block .portal-submissions-load-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(108, 117, 125, 0.9); /* ~Bootstrap secondary */
+ background: rgba(108, 117, 125, 0.06);
+ font-size: 0.78rem;
+ line-height: 1.4;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.portal-submissions-block .portal-submissions-load-btn:hover,
+.portal-submissions-block .portal-submissions-load-btn:focus-visible {
+ background: rgba(108, 117, 125, 0.18);
+}
+
+body.dark-mode .portal-submissions-block .portal-submissions-load-btn {
+ border-color: rgba(200, 200, 200, 0.7);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+body.dark-mode .portal-submissions-block .portal-submissions-load-btn:hover,
+body.dark-mode .portal-submissions-block .portal-submissions-load-btn:focus-visible {
+ background: rgba(255, 255, 255, 0.10);
}
\ No newline at end of file
diff --git a/public/js/adminOnlyOffice.js b/public/js/adminOnlyOffice.js
new file mode 100644
index 0000000..9a3a399
--- /dev/null
+++ b/public/js/adminOnlyOffice.js
@@ -0,0 +1,511 @@
+// public/js/adminOnlyOffice.js
+import { t } from './i18n.js?v={{APP_QVER}}';
+import { showToast } from './domUtils.js?v={{APP_QVER}}';
+
+/**
+ * Translate with fallback
+ */
+const tf = (key, fallback) => {
+ const v = t(key);
+ return (v && v !== key) ? v : fallback;
+};
+
+/**
+ * Local masked-input renderer (copied from adminPanel.js style)
+ */
+function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
+ const type = isSecret ? 'password' : 'text';
+ const disabled = hasValue
+ ? 'disabled data-replace="0" placeholder="•••••• (saved)"'
+ : 'data-replace="1"';
+ const replaceBtn = hasValue
+ ? `Replace `
+ : '';
+ const note = hasValue
+ ? `Saved — leave blank to keep `
+ : '';
+
+ return `
+
+ `;
+}
+
+/**
+ * Local "Replace" wiring (copied from adminPanel.js style, but scoped)
+ */
+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 });
+ });
+}
+
+/**
+ * Trusted origin helper (mirror of your inline logic)
+ */
+function getTrustedDocsOrigin(raw) {
+ try {
+ const u = new URL(String(raw || '').trim());
+ if (!/^https?:$/.test(u.protocol)) return null; // only http/https
+ if (u.username || u.password) return null; // no creds in URL
+ return u.origin;
+ } catch {
+ return null;
+ }
+}
+
+function buildOnlyOfficeApiUrl(origin) {
+ const u = new URL('/web-apps/apps/api/documents/api.js', origin);
+ u.searchParams.set('probe', String(Date.now()));
+ return u.toString();
+}
+
+/**
+ * Lightweight JSON helper for this module
+ */
+async function safeJsonLocal(res) {
+ const txt = await res.text();
+ let body = null;
+ try { body = txt ? JSON.parse(txt) : null; } catch { /* ignore */ }
+ if (!res.ok) {
+ const msg =
+ (body && (body.error || body.message)) ||
+ (txt && txt.trim()) ||
+ `HTTP ${res.status}`;
+ const err = new Error(msg);
+ err.status = res.status;
+ throw err;
+ }
+ return body ?? {};
+}
+
+/**
+ * Script probe for api.js (mirrors old ooProbeScript)
+ */
+async function ooProbeScript(docsOrigin) {
+ return new Promise(resolve => {
+ const base = getTrustedDocsOrigin(docsOrigin);
+ if (!base) { resolve({ ok: false }); return; }
+
+ const src = buildOnlyOfficeApiUrl(base);
+ const s = document.createElement('script');
+ s.id = 'ooProbeScript';
+ s.async = true;
+ s.src = src;
+
+ const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
+ if (nonce) s.setAttribute('nonce', nonce);
+
+ const cleanup = () => { try { s.remove(); } catch { /* ignore */ } };
+
+ s.onload = () => { cleanup(); resolve({ ok: true }); };
+ s.onerror = () => { cleanup(); resolve({ ok: false }); };
+
+ // origin is validated, path is fixed => safe
+ document.head.appendChild(s);
+ });
+}
+
+/**
+ * Iframe probe for DS (mirrors old ooProbeFrame)
+ */
+async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
+ return new Promise(resolve => {
+ const base = getTrustedDocsOrigin(docsOrigin);
+ if (!base) { resolve({ ok: false }); return; }
+
+ const f = document.createElement('iframe');
+ f.id = 'ooProbeFrame';
+ f.src = base;
+ f.style.display = 'none';
+
+ const cleanup = () => { try { f.remove(); } catch { /* ignore */ } };
+ const t = setTimeout(() => {
+ cleanup();
+ resolve({ ok: false, timeout: true });
+ }, timeoutMs);
+
+ f.onload = () => {
+ clearTimeout(t);
+ cleanup();
+ resolve({ ok: true });
+ };
+ f.onerror = () => {
+ clearTimeout(t);
+ cleanup();
+ resolve({ ok: false });
+ };
+
+ // src constrained to validated http/https origin
+ document.body.appendChild(f);
+ });
+}
+
+/**
+ * Copy helpers (same behavior you had before)
+ */
+async function copyToClipboard(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // fall through
+ }
+ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ return ok;
+ } catch {
+ return false;
+ }
+}
+
+function selectElementContents(el) {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+}
+
+/**
+ * Builds the ONLYOFFICE test card and wires Run tests button
+ */
+function attachOnlyOfficeTests(container) {
+ const testBox = document.createElement('div');
+ testBox.className = 'card';
+ testBox.style.marginTop = '12px';
+ testBox.innerHTML = `
+
+
+ Test ONLYOFFICE connection
+ Run tests
+ ⏳
+
+
+
+ These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
+
+
+ `;
+ container.appendChild(testBox);
+
+ const spinner = testBox.querySelector('#ooTestSpinner');
+ const out = testBox.querySelector('#ooTestResults');
+
+ function ooRow(label, status, detail = '') {
+ const li = document.createElement('li');
+ li.style.margin = '6px 0';
+ const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️' : '❌';
+ li.innerHTML =
+ `${icon} ` +
+ ` ${label} ` +
+ (detail ? ` — ${detail} ` : '');
+ return li;
+ }
+
+ function ooClear() {
+ while (out.firstChild) out.removeChild(out.firstChild);
+ }
+
+ async function runOnlyOfficeTests() {
+ const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
+
+ spinner.style.display = 'inline';
+ ooClear();
+
+ // 1) FileRise status
+ let statusOk = false;
+ try {
+ const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
+ const statusJson = await r.json().catch(() => ({}));
+ if (r.ok) {
+ if (statusJson.enabled) {
+ out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
+ statusOk = true;
+ } else {
+ out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
+ }
+ } else {
+ out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
+ }
+ } catch (e) {
+ out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
+ }
+
+ // 2) Secret presence (fresh read)
+ try {
+ const cfg = await fetch('/api/admin/getConfig.php', {
+ credentials: 'include',
+ cache: 'no-store'
+ }).then(r => r.json());
+ const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
+ out.appendChild(
+ ooRow(
+ 'JWT secret saved',
+ hasSecret ? 'ok' : 'fail',
+ hasSecret ? 'Present' : 'Missing'
+ )
+ );
+ } catch {
+ out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
+ }
+
+ // 3) Callback reachable
+ try {
+ const r = await fetch('/api/onlyoffice/callback.php?ping=1', {
+ credentials: 'include',
+ cache: 'no-store'
+ });
+ if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
+ else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
+ } catch {
+ out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
+ }
+
+ // Basic sanity on origin
+ if (!/^https?:\/\//i.test(docsOrigin)) {
+ out.appendChild(
+ ooRow(
+ 'Document Server Origin',
+ 'fail',
+ 'Enter a valid http(s) origin (e.g., https://docs.example.com)'
+ )
+ );
+ spinner.style.display = 'none';
+ return;
+ }
+
+ // 4a) api.js
+ const sRes = await ooProbeScript(docsOrigin);
+ out.appendChild(
+ ooRow(
+ 'Load api.js',
+ sRes.ok ? 'ok' : 'fail',
+ sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'
+ )
+ );
+
+ // 4b) iframe
+ const fRes = await ooProbeFrame(docsOrigin);
+ out.appendChild(
+ ooRow(
+ 'Embed DS iframe',
+ fRes.ok ? 'ok' : 'fail',
+ fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'
+ )
+ );
+
+ if (!statusOk || !sRes.ok || !fRes.ok) {
+ const tip = document.createElement('li');
+ tip.style.marginTop = '8px';
+ tip.innerHTML =
+ '💡 Tip: Use the CSP helper below to include your Document Server in ' +
+ 'script-src, connect-src, and frame-src.';
+ out.appendChild(tip);
+ }
+
+ spinner.style.display = 'none';
+ }
+
+ testBox.querySelector('#ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
+}
+
+/**
+ * CSP helper card (Apache + Nginx snippets)
+ */
+function attachOnlyOfficeCspHelper(container) {
+ const cspHelp = document.createElement('div');
+ cspHelp.className = 'alert alert-info';
+ cspHelp.style.marginTop = '12px';
+ cspHelp.innerHTML = `
+
+ Content-Security-Policy help
+ Copy
+ Select
+
+
+ Add/replace this line in public/.htaccess (Apache). It allows loading ONLYOFFICE's api.js,
+ embedding the editor iframe, and letting the script make XHR to your Document Server.
+
+
+
+ If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
+ Also note: if your site is https://, your ONLYOFFICE server must be https:// too,
+ otherwise the browser will block it as mixed content.
+
+
+ Nginx equivalent
+
+
+ `;
+ container.appendChild(cspHelp);
+
+ const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
+
+ function buildCspApache(originRaw) {
+ const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
+ const api = `${o}/web-apps/apps/api/documents/api.js`;
+ return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
+ }
+
+ function buildCspNginx(originRaw) {
+ const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
+ const api = `${o}/web-apps/apps/api/documents/api.js`;
+ return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
+ }
+
+ const ooDocsInput = document.getElementById('ooDocsOrigin');
+ const cspPre = document.getElementById('ooCspSnippet');
+ const cspPreNgx = document.getElementById('ooCspSnippetNginx');
+
+ function refreshCsp() {
+ const raw = (ooDocsInput?.value || '').trim();
+ const base = getTrustedDocsOrigin(raw) || raw;
+ cspPre.textContent = buildCspApache(base);
+ cspPreNgx.textContent = buildCspNginx(base);
+ }
+
+ ooDocsInput?.addEventListener('input', refreshCsp);
+ refreshCsp();
+
+ document.getElementById('copyOoCsp')?.addEventListener('click', async () => {
+ const txt = (cspPre.textContent || '').trim();
+ const ok = await copyToClipboard(txt);
+ if (ok) {
+ showToast('CSP line copied.');
+ } else {
+ try { selectElementContents(cspPre); } catch { /* ignore */ }
+ const reason = window.isSecureContext ? '' : ' (page is not HTTPS or localhost)';
+ showToast('Copy failed' + reason + '. Press Ctrl/Cmd+C to copy.');
+ }
+ });
+
+ document.getElementById('selectOoCsp')?.addEventListener('click', () => {
+ try {
+ selectElementContents(cspPre);
+ showToast('Selected — press Ctrl/Cmd+C');
+ } catch {
+ /* ignore */
+ }
+ });
+}
+
+/**
+ * Public: build + wire ONLYOFFICE admin section
+ */
+export function initOnlyOfficeUI({ config }) {
+ const sec = document.getElementById('onlyofficeContent');
+ if (!sec) return;
+
+ const onlyCfg = config.onlyoffice || {};
+ const hasOOSecret = !!onlyCfg.hasJwtSecret;
+ window.__HAS_OO_SECRET = hasOOSecret;
+
+ // Base content
+ sec.innerHTML = `
+
+
+ Enable ONLYOFFICE integration
+
+
+
+ Document Server Origin:
+
+
+ Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
+
+
+
+ ${renderMaskedInput({
+ id: 'ooJwtSecret',
+ label: 'JWT Secret',
+ hasValue: hasOOSecret,
+ isSecret: true
+ })}
+ `;
+
+ wireReplaceButtons(sec);
+
+ // Tests + CSP helper
+ attachOnlyOfficeTests(sec);
+ attachOnlyOfficeCspHelper(sec);
+
+ // Initial values
+ const enabled = !!onlyCfg.enabled;
+ const docsOrigin = onlyCfg.docsOrigin || '';
+
+ const enabledEl = document.getElementById('ooEnabled');
+ const originEl = document.getElementById('ooDocsOrigin');
+
+ if (enabledEl) enabledEl.checked = enabled;
+ if (originEl) originEl.value = docsOrigin;
+
+ // Locking (managed in config.php)
+ const locked = !!onlyCfg.lockedByPhp;
+ window.__OO_LOCKED = locked;
+ if (locked) {
+ sec.querySelectorAll('input,button').forEach(el => {
+ el.disabled = true;
+ });
+ const note = document.createElement('div');
+ note.className = 'form-text';
+ note.style.marginTop = '6px';
+ note.textContent = 'Managed by config.php — edit ONLYOFFICE_* constants there.';
+ sec.appendChild(note);
+ }
+}
+
+/**
+ * Public: inject ONLYOFFICE settings into payload (used in handleSave)
+ */
+export function collectOnlyOfficeSettingsForSave(payload) {
+ const ooEnabledEl = document.getElementById('ooEnabled');
+ const ooDocsOriginEl = document.getElementById('ooDocsOrigin');
+ const ooSecretEl = document.getElementById('ooJwtSecret');
+
+ const onlyoffice = {
+ enabled: !!(ooEnabledEl && ooEnabledEl.checked),
+ docsOrigin: (ooDocsOriginEl && ooDocsOriginEl.value.trim()) || ''
+ };
+
+ if (!window.__OO_LOCKED && ooSecretEl) {
+ const val = ooSecretEl.value.trim();
+ const hasSaved = !!window.__HAS_OO_SECRET;
+ const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
+ if (shouldReplace && val !== '') {
+ onlyoffice.jwtSecret = val;
+ }
+ }
+
+ payload.onlyoffice = onlyoffice;
+ return payload;
+}
\ No newline at end of file
diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js
index c0ec81f..79e78bc 100644
--- a/public/js/adminPanel.js
+++ b/public/js/adminPanel.js
@@ -4,6 +4,9 @@ import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
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}}';
function normalizeLogoPath(raw) {
if (!raw) return '';
@@ -274,297 +277,6 @@ async function safeJson(res) {
return body ?? {};
}
-// ————— Inject updated styles —————
-(function () {
- if (document.getElementById('adminPanelStyles')) return;
- const style = document.createElement('style');
- style.id = 'adminPanelStyles';
- style.textContent = `
- /* Modal sizing */
- #adminPanelModal .modal-content {
- max-width: 1100px;
- width: 60% !important;
- background: #fff !important;
- color: #000 !important;
- border: 1px solid #ccc !important;
- }
- @media (max-width: 900px) {
- #adminPanelModal .modal-content {
- width: 90% !important;
- max-width: none !important;
- }
- }
- .dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
- .dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
- .dark-mode .form-control::placeholder { color:#888; }
-
- .section-header {
- background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
- display:flex; align-items:center; justify-content:space-between; margin-top:16px;
- }
- .section-header:first-of-type { margin-top:0; }
- .section-header.collapsed .material-icons { transform:rotate(-90deg); }
- .section-header .material-icons { transition:transform .3s; color:#444; }
- .dark-mode .section-header { background:#3a3a3a; color:#eee; }
- .dark-mode .section-header .material-icons { color:#ccc; }
-
- .section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
-
- #adminPanelModal .editor-close-btn {
- position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
- font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%;
- text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
- border:2px solid transparent; transition:all .3s;
- }
- #adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
- .dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
-
- .action-row { display:flex; justify-content:space-between; margin-top:15px; }
-
- /* ---------- Folder access editor ---------- */
- .folder-access-toolbar {
- display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
- }
- .folder-access-list {
- --col-perm: 84px;
- --col-folder-min: 340px;
- max-height: 320px;
- overflow: auto;
- border: 1px solid #ccc;
- border-radius: 6px;
- padding: 0;
- }
- .dark-mode .folder-access-list { border-color:#555; }
-
- .folder-access-header,
- .folder-access-row {
- display: grid;
- grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(14, var(--col-perm));
- gap: 8px;
- align-items: center;
- padding: 8px 10px;
- }
- .folder-access-header {
- position: sticky;
- top: 0;
- z-index: 2;
- background: #fff;
- font-weight: 700;
- border-bottom: 1px solid rgba(0,0,0,0.12);
- }
- .dark-mode .folder-access-header { background:#2c2c2c; }
-
- .folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
- .folder-access-row:last-child { border-bottom: none; }
-
- .perm-col { text-align:center; white-space:nowrap; }
- .folder-access-header > div { white-space: nowrap; }
-
- .folder-badge {
- display:inline-flex; align-items:center; gap:6px;
- font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
- min-width: 0;
- }
-
- .muted { opacity:.65; font-size:.9em; }
-
- /* Inheritance visuals */
- .inherited-row {
- opacity: 0.8;
- background: rgba(32, 132, 255, 0.06);
- }
- .inherited-tag {
- font-size: 11px;
- padding: 2px 6px;
- border-radius: 10px;
- background: rgba(32,132,255,0.12);
- color: #2064ff;
- margin-left: 6px;
- }
- .dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
- .dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
-
- @media (max-width: 900px) {
- .folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
- }
-
- /* Folder cell: horizontal-only scroll */
- .folder-cell{
- overflow-x:auto;
- overflow-y:hidden;
- white-space:nowrap;
- -webkit-overflow-scrolling:touch;
- }
- /* nicer thin scrollbar (supported browsers) */
- .folder-cell::-webkit-scrollbar{ height:8px; }
- .folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
- .dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
-
- /* Badge now doesn't clip; let the wrapper handle scroll */
- .folder-badge{
- display:inline-flex; align-items:center; gap:6px;
- font-weight:600;
- min-width:0; /* allow child to be as wide as needed inside scroller */
- }
- .group-members-chips {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- }
-
- .group-member-pill {
- display: inline-flex;
- align-items: center;
- padding: 2px 6px;
- border-radius: 999px;
- font-size: 11px;
- background-color: #1e88e5;
- color: #fff;
- }
-
- .dark-mode .group-member-pill {
- background-color: #1565c0;
- color: #fff;
- }
- /* Client portal cards */
- #clientPortalsBody .portal-card {
- position: relative;
- border-radius: 12px;
- border: 1px solid #ddd;
- padding: 10px 12px 8px;
- margin-bottom: 10px;
- }
- .dark-mode #clientPortalsBody .portal-card {
- border-color: #555;
- background: #1f1f1f;
- }
-
- .portal-card-header {
- display:flex;
- align-items:center;
- gap:8px;
- cursor:pointer;
- padding:4px 4px 4px 0;
- }
- .portal-card-header .portal-card-caret {
- display:inline-block;
- font-size:14px;
- transform:rotate(-90deg);
- transition:transform .15s ease;
- }
- .portal-card-header[aria-expanded="true"] .portal-card-caret {
- transform:rotate(0deg);
- }
- .portal-card-header-main {
- display:flex;
- flex-wrap:wrap;
- gap:6px;
- align-items:baseline;
- }
- .portal-card-header-main strong {
- font-size:.9rem;
- }
- .portal-card-header-main .portal-card-slug {
- font-family:monospace;
- font-size:.8rem;
- opacity:.75;
- }
-
- .portal-card-delete,
- .group-card-delete {
- position:absolute;
- top:10px;
- right:6px;
- width:30px;
- height:30px;
- border-radius:50%;
- display:flex;
- align-items:center;
- justify-content:center;
- padding:0;
- }
- .group-card-delete {
-
- top:4px;
-
- }
-
- .portal-card-body {
- margin-top:6px;
- }
-
- #clientPortalsBody .portal-meta-row {
- display:flex;
- flex-wrap:wrap;
- gap:8px;
- align-items:center;
- margin-top:6px;
- }
- #clientPortalsBody .portal-meta-row label {
- margin:0;
- font-size:.8rem;
- }
-
- /* Make date input look consistent */
- #clientPortalsBody input[type="date"].form-control-sm {
- border-radius:.25rem;
- }
- /* -------- Client portals: Expires alignment + date styling -------- */
- #clientPortalsBody .portal-expires-group {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- }
- #clientPortalsBody .portal-expires-group label {
- margin: 0;
- font-size: 0.85rem;
- }
- #clientPortalsBody .portal-expiry-input {
- max-width: 170px;
- border-radius: 6px;
- }
- .dark-mode #clientPortalsBody .portal-expiry-input {
- background-color: #333;
- border-color: #555;
- color: #eee;
- }
-
- #clientPortalsBody .portal-submissions-block {
- margin-top: 8px;
- padding-top: 6px;
- border-top: 1px dashed rgba(0,0,0,0.1);
- }
- #clientPortalsBody .portal-submissions-list {
- max-height: 180px;
- overflow: auto;
- margin-top: 4px;
- padding: 4px;
- border-radius: 6px;
- border: 1px solid rgba(0,0,0,0.08);
- background: rgba(0,0,0,0.02);
- font-size: 0.8rem;
- }
- .dark-mode #clientPortalsBody .portal-submissions-list {
- border-color: #555;
- background: rgba(255,255,255,0.02);
- }
- #clientPortalsBody .portal-submissions-item {
- padding: 4px 2px;
- border-bottom: 1px solid rgba(0,0,0,0.05);
- }
- #clientPortalsBody .portal-submissions-item:last-child {
- border-bottom: none;
- }
- #clientPortalsBody .portal-submissions-meta {
- opacity: 0.75;
- font-size: 0.75rem;
- }
-
- `;
- document.head.appendChild(style);
-})();
-// ————————————————————————————————————
-
let originalAdminConfig = {};
function captureInitialAdminConfig() {
const ht = document.getElementById("headerTitle");
@@ -1186,311 +898,8 @@ export function openAdminPanel() {
`;
- // ONLYOFFICE Content
- const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret);
- window.__HAS_OO_SECRET = hasOOSecret;
- document.getElementById("onlyofficeContent").innerHTML = `
-
-
- Enable ONLYOFFICE integration
-
-
-
- Document Server Origin:
-
- Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”.
-
-
- ${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })}
-`;
-
- wireReplaceButtons(document.getElementById("onlyofficeContent"));
-
-
-
-
-
- // --- Test ONLYOFFICE block ---
- const testBox = document.createElement("div");
- testBox.className = "card";
- testBox.style.marginTop = "12px";
- testBox.innerHTML = `
-
-
- Test ONLYOFFICE connection
- Run tests
- ⏳
-
-
-
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
-
- `;
- document.getElementById("onlyofficeContent").appendChild(testBox);
-
- // Util: tiny UI helpers for results
- function ooRow(label, status, detail = "") {
- const li = document.createElement("li");
- li.style.margin = "6px 0";
- const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌";
- li.innerHTML = `${icon} ${label} ${detail ? ` — ${detail} ` : ""}`;
- return li;
- }
- function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
-
- // --- ONLYOFFICE URL sanitizers ---
- function getTrustedDocsOrigin(raw) {
- try {
- const u = new URL(String(raw || "").trim());
- if (!/^https?:$/.test(u.protocol)) return null; // only http/https
- if (u.username || u.password) return null; // no creds in URL
- return u.origin; // scheme://host[:port]
- } catch {
- return null;
- }
- }
-
- function buildOnlyOfficeApiUrl(origin) {
- // fixed path; caller already validated/normalized origin
- const u = new URL('/web-apps/apps/api/documents/api.js', origin);
- u.searchParams.set('probe', String(Date.now()));
- return u.toString();
- }
-
-
- // Probes that don’t explode your state
- async function ooProbeScript(docsOrigin) {
- return new Promise(resolve => {
- const base = getTrustedDocsOrigin(docsOrigin);
- if (!base) { resolve({ ok: false }); return; }
-
- const src = buildOnlyOfficeApiUrl(base);
- const s = document.createElement('script');
- s.id = 'ooProbeScript';
- s.async = true;
- s.src = src;
-
- // If you set a CSP nonce in a , attach it:
- const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
- if (nonce) s.setAttribute('nonce', nonce);
-
- const cleanup = () => { try { s.remove(); } catch { } };
-
- s.onload = () => { cleanup(); resolve({ ok: true }); };
- s.onerror = () => { cleanup(); resolve({ ok: false }); };
-
- // codeql[js/xss-through-dom]: the origin is validated (http/https, no creds),
- // and the path is fixed to ONLYOFFICE api.js via URL(), so this is safe.
- document.head.appendChild(s);
- });
- }
- async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
- return new Promise(resolve => {
- const base = getTrustedDocsOrigin(docsOrigin);
- if (!base) { resolve({ ok: false }); return; }
-
- const f = document.createElement('iframe');
- f.id = 'ooProbeFrame';
- f.src = base; // only the sanitized origin
- f.style.display = 'none';
-
- // Optional: keep it extra constrained while probing.
- // If your DS needs broader privileges, you can drop sandbox.
- // f.sandbox = 'allow-same-origin allow-scripts';
-
- const cleanup = () => { try { f.remove(); } catch { } };
- const t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs);
-
- f.onload = () => { clearTimeout(t); cleanup(); resolve({ ok: true }); };
- f.onerror = () => { clearTimeout(t); cleanup(); resolve({ ok: false }); };
-
- // codeql[js/xss-through-dom]: src is constrained to a validated http/https origin.
- document.body.appendChild(f);
- });
- }
- // Main test runner
- async function runOnlyOfficeTests() {
- const spinner = document.getElementById('ooTestSpinner');
- const out = document.getElementById('ooTestResults');
- const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
-
- spinner.style.display = 'inline';
- ooClear(out);
-
- // 1) FileRise status
- let statusOk = false, statusJson = null;
- try {
- const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
- statusJson = await r.json().catch(() => ({}));
- if (r.ok) {
- if (statusJson.enabled) {
- out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
- statusOk = true;
- } else {
- // Disabled usually means missing secret or origin; we’ll dig deeper below.
- out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
- }
- } else {
- out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
- }
- } catch (e) {
- out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
- }
-
- // 2) Secret presence (fresh read)
- try {
- const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json());
- const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
- out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing'));
- } catch {
- out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
- }
-
- // 3) Callback reachable (basic ping)
- try {
- const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' });
- if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
- else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
- } catch {
- out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
- }
-
- // Early sanity on origin
- if (!/^https?:\/\//i.test(docsOrigin)) {
- out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)'));
- spinner.style.display = 'none';
- return;
- }
-
- // 4a) Can browser load api.js (also surfaces CSP script-src issues)
- const sRes = await ooProbeScript(docsOrigin);
- out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'));
-
- // 4b) Can browser embed DS in an iframe (CSP frame-src)
- const fRes = await ooProbeFrame(docsOrigin);
- out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'));
-
- // Optional tip if we see common red flags
- if (!statusOk || !sRes.ok || !fRes.ok) {
- const tip = document.createElement('li');
- tip.style.marginTop = '8px';
- tip.innerHTML = "💡 Tip: Use the CSP helper above to include your Document Server in script-src, connect-src, and frame-src.";
- out.appendChild(tip);
- }
-
- spinner.style.display = 'none';
- }
-
- // Wire the button
- document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
-
-
-
- // Append CSP help box
- // --- CSP help box (replace your whole block with this) ---
- const ooSec = document.getElementById("onlyofficeContent");
- const cspHelp = document.createElement("div");
- cspHelp.className = "alert alert-info";
- cspHelp.style.marginTop = "12px";
- cspHelp.innerHTML = `
-
- Content-Security-Policy help
- Copy
- Select
-
-
- Add/replace this line in public/.htaccess (Apache). It allows loading ONLYOFFICE's api.js,
- embedding the editor iframe, and letting the script make XHR to your Document Server.
-
-
-
- If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
- Also note: if your site is https://, your ONLYOFFICE server must be https:// too,
- otherwise the browser will block it as mixed content.
-
-
- Nginx equivalent
-
-
-`;
- ooSec.appendChild(cspHelp);
-
- const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
-
- function buildCspApache(originRaw) {
- const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
- const api = `${o}/web-apps/apps/api/documents/api.js`;
- return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
- }
- function buildCspNginx(originRaw) {
- const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
- const api = `${o}/web-apps/apps/api/documents/api.js`;
- return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
- }
-
- const ooDocsInput = document.getElementById("ooDocsOrigin");
- const cspPre = document.getElementById("ooCspSnippet");
- const cspPreNgx = document.getElementById("ooCspSnippetNginx");
-
- function refreshCsp() {
- const raw = (ooDocsInput?.value || "").trim();
- const base = getTrustedDocsOrigin(raw) || raw; // fall back to raw so users see their input
- cspPre.textContent = buildCspApache(base);
- cspPreNgx.textContent = buildCspNginx(base);
- }
- ooDocsInput?.addEventListener("input", refreshCsp);
- refreshCsp();
-
- // ---- Copy helpers (with robust fallback) ----
- async function copyToClipboard(text) {
- // Best path: async clipboard API in a secure context (https/localhost)
- if (navigator.clipboard && window.isSecureContext) {
- try { await navigator.clipboard.writeText(text); return true; }
- catch (_) { /* fall through */ }
- }
- // Fallback for http or blocked clipboard: hidden textarea + execCommand
- try {
- const ta = document.createElement('textarea');
- ta.value = text;
- ta.setAttribute('readonly', '');
- ta.style.position = 'fixed';
- ta.style.left = '-9999px';
- document.body.appendChild(ta);
- ta.select();
- const ok = document.execCommand('copy'); // deprecated but still widely supported
- document.body.removeChild(ta);
- return ok;
- } catch (_) {
- return false;
- }
- }
- function selectElementContents(el) {
- const range = document.createRange();
- range.selectNodeContents(el);
- const sel = window.getSelection();
- sel.removeAllRanges();
- sel.addRange(range);
- }
-
- document.getElementById("copyOoCsp")?.addEventListener("click", async () => {
- const txt = (cspPre.textContent || "").trim();
- const ok = await copyToClipboard(txt);
- if (ok) {
- showToast("CSP line copied.");
- } else {
- // Auto-select so the user can Ctrl/Cmd+C as a last resort
- try { selectElementContents(cspPre); } catch { }
- const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)";
- showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy.");
- }
- });
-
- document.getElementById("selectOoCsp")?.addEventListener("click", () => {
- try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); }
- catch { /* ignore */ }
- });
-
- document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
- document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
+ // ONLYOFFICE section (moved into adminOnlyOffice.js)
+ initOnlyOfficeUI({ config });
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
@@ -1804,59 +1213,7 @@ export function openAdminPanel() {
}
});
- // --- Sponsor (fixed, non-editable) ---
- const SPONSOR_GH = "https://github.com/sponsors/error311";
- const SPONSOR_KOFI = "https://ko-fi.com/error311";
-
- document.getElementById("sponsorContent").innerHTML = `
-
-
-
-
- ${(typeof tf === 'function'
- ? tf("sponsor_note_fixed", "Please consider supporting ongoing development.")
- : "Please consider supporting ongoing development.")}
-`;
-
- // Wire copy + open (no changes tracked)
- const ghInput = document.getElementById("sponsorGitHub");
- const kfInput = document.getElementById("sponsorKoFi");
-
- document.getElementById("copySponsorGitHub").addEventListener("click", async () => {
- try { await navigator.clipboard.writeText(ghInput.value); } catch { }
- showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!");
- });
- document.getElementById("copySponsorKoFi").addEventListener("click", async () => {
- try { await navigator.clipboard.writeText(kfInput.value); } catch { }
- showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!");
- });
-
- document.getElementById("openSponsorGitHub").href = SPONSOR_GH;
- document.getElementById("openSponsorKoFi").href = SPONSOR_KOFI;
+
const userMgmt = document.getElementById("userManagementContent");
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
@@ -1875,17 +1232,9 @@ export function openAdminPanel() {
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
- // remember lock for handleSave
- window.__OO_LOCKED = !!(config.onlyoffice && config.onlyoffice.lockedByPhp);
- if (window.__OO_LOCKED) {
- const sec = document.getElementById("onlyofficeContent");
- sec.querySelectorAll("input,button").forEach(el => el.disabled = true);
- const note = document.createElement("div");
- note.className = "form-text";
- note.style.marginTop = "6px";
- note.textContent = "Managed by config.php — edit ONLYOFFICE_* constants there.";
- sec.appendChild(note);
- }
+ // Rebuild ONLYOFFICE section from fresh config
+ initOnlyOfficeUI({ config });
+
captureInitialAdminConfig();
} else {
@@ -1923,6 +1272,17 @@ export function openAdminPanel() {
} catch (e) {
console.error('Failed to init Storage / Disk Usage section', e);
}
+
+ try {
+ initAdminSponsorSection({
+ container: document.getElementById('sponsorContent'),
+ t,
+ tf,
+ showToast
+ });
+ } catch (e) {
+ console.error('Failed to init Sponsor / Donations section', e);
+ }
})
.catch(() => {/* if even fetching fails, open empty panel */ });
}
@@ -1968,24 +1328,8 @@ function handleSave() {
payload.oidc.clientSecret = secVal;
}
- // ---- ONLYOFFICE payload ----
- const ooSecretEl = document.getElementById("ooJwtSecret");
-
- payload.onlyoffice = {
- enabled: document.getElementById("ooEnabled").checked,
- docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
- };
-
- // Only send JWT secret if NOT locked by PHP and user chose Replace / first-time set
- if (!window.__OO_LOCKED && ooSecretEl) {
- const val = ooSecretEl.value.trim();
- const hasSaved = !!window.__HAS_OO_SECRET; // set in openAdminPanel
- const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
-
- if (shouldReplace && val !== "") {
- payload.onlyoffice.jwtSecret = val;
- }
- }
+ // ONLYOFFICE settings (moved into adminOnlyOffice.js)
+ collectOnlyOfficeSettingsForSave(payload);
// --- save call (unchanged) ---
fetch('/api/admin/updateConfig.php', {
@@ -2587,33 +1931,6 @@ async function fetchAllGroups() {
: {};
}
-async function fetchAllPortals() {
- const res = await fetch('/api/pro/portals/list.php', {
- credentials: 'include',
- headers: { 'X-CSRF-Token': window.csrfToken || '' }
- });
- const data = await safeJson(res);
- // backend returns { success, portals: { slug: {...} } }
- return data && typeof data === 'object' && data.portals && typeof data.portals === 'object'
- ? data.portals
- : {};
-}
-
-async function saveAllPortals(portals) {
- const res = await fetch('/api/pro/portals/save.php', {
- method: 'POST',
- credentials: 'include',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': window.csrfToken || ''
- },
- body: JSON.stringify({ portals })
- });
- return await safeJson(res);
-}
-
-let __portalsCache = {};
-
async function saveAllGroups(groups) {
const res = await fetch('/api/pro/groups/save.php', {
method: 'POST',
@@ -2627,718 +1944,6 @@ async function saveAllGroups(groups) {
return await safeJson(res);
}
-async function openClientPortalsModal() {
- 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('clientPortalsModal');
- if (!modal) {
- modal = document.createElement('div');
- modal.id = 'clientPortalsModal';
- modal.style.cssText = `
- position:fixed; inset:0; background:${overlayBg};
- display:flex; align-items:center; justify-content:center; z-index:3650;
- `;
- modal.innerHTML = `
-
-
×
-
-
Client Portals
-
- Create upload portals that point to specific folders. Clients can upload
- (and optionally download) files without seeing your full FileRise UI.
-
-
-
-
- cloud_upload
- Add portal
-
-
-
-
-
- ${t('loading')}…
-
-
-
- ${t('cancel')}
- ${t('save_settings')}
-
-
- `;
- document.body.appendChild(modal);
-
- document.getElementById('closeClientPortalsModal').onclick = () => (modal.style.display = 'none');
- document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
- document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
- document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
- } else {
- modal.style.background = overlayBg;
- const content = modal.querySelector('.modal-content');
- if (content) {
- content.style.background = contentBg;
- content.style.color = contentFg;
- content.style.border = `1px solid ${borderCol}`;
- }
- }
-
- modal.style.display = 'flex';
- await loadClientPortalsList();
-}
-
-async function loadClientPortalsList(useCacheOnly) {
- const body = document.getElementById('clientPortalsBody');
- const status = document.getElementById('clientPortalsStatus');
- if (!body) return;
-
- body.textContent = `${t('loading')}…`;
- if (status) {
- status.textContent = '';
- status.className = 'small text-muted';
- }
-
- try {
- let portals;
- if (useCacheOnly && __portalsCache && Object.keys(__portalsCache).length) {
- portals = __portalsCache;
- } else {
- portals = await fetchAllPortals();
- __portalsCache = portals || {};
- }
-
- const slugs = Object.keys(__portalsCache).sort((a, b) => a.localeCompare(b));
- if (!slugs.length) {
- body.innerHTML = `No client portals defined yet. Click “Add portal” to create one.
`;
- return;
- }
-
- let html = '';
- slugs.forEach(slug => {
-
- const origin = window.location.origin || '';
- const portalPath = '/portal/' + encodeURIComponent(slug);
- const portalUrl = origin ? origin + portalPath : portalPath;
-
- const p = __portalsCache[slug] || {};
- const label = p.label || slug;
- const folder = p.folder || '';
- const clientEmail = p.clientEmail || '';
- const uploadOnly = !!p.uploadOnly;
- const allowDownload = p.allowDownload !== false; // default true
- const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
- const brandColor = p.brandColor || '';
- const footerText = p.footerText || '';
- const formDefaults = p.formDefaults || {};
- const formRequired = p.formRequired || {};
- const defName = formDefaults.name || '';
- const defEmail = formDefaults.email || '';
- const defRef = formDefaults.reference || '';
- const defNotes = formDefaults.notes || '';
-
- const title = p.title || '';
- const introText = p.introText || '';
- const requireForm = !!p.requireForm;
-
- html += `
-
-
-
-
- delete
-
-
-
-
-
- Portal slug:
-
-
-
- Display name:
-
-
-
-
-
-
-
-
-
-
-
-
-
- Portal title (optional):
-
-
-
-
-
- Instructions (shown on portal page):
-
-
-
-
-
- Require info form before upload
-
-
-
-
-
-
- Accent color:
-
-
-
-
-
-
- Footer text (shown at bottom of portal):
-
-
-
-
-
-
-
-
- `;
- });
- body.innerHTML = html;
-
- // Wire collapse / expand for each portal card
- body.querySelectorAll('.portal-card').forEach(card => {
- const header = card.querySelector('.portal-card-header');
- const bodyEl = card.querySelector('.portal-card-body');
- const caret = card.querySelector('.portal-card-caret');
- if (!header || !bodyEl) return;
-
- const setExpanded = (expanded) => {
- header.setAttribute('aria-expanded', expanded ? 'true' : 'false');
- bodyEl.style.display = expanded ? 'block' : 'none';
- if (caret) {
- caret.textContent = expanded ? '▾' : '▸';
- }
- };
-
- setExpanded(false);
-
- const toggle = () => {
- const expanded = header.getAttribute('aria-expanded') === 'true';
- setExpanded(!expanded);
- };
-
- header.addEventListener('click', toggle);
- header.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- toggle();
- }
- });
- });
-
- // Wire delete buttons
- body.querySelectorAll('[data-portal-action="delete"]').forEach(btn => {
- btn.addEventListener('click', () => {
- const card = btn.closest('.card');
- if (!card) return;
- const slug = card.getAttribute('data-portal-slug');
- if (slug && __portalsCache[slug]) {
- delete __portalsCache[slug];
- }
- card.remove();
- });
- });
- attachPortalSubmissionsUI();
- } catch (e) {
- console.error(e);
- body.innerHTML = `Error loading client portals.
`;
- if (status) {
- status.textContent = 'Error loading client portals.';
- status.className = 'small text-danger';
- }
- }
-}
-
-function addEmptyPortalRow() {
- if (!__portalsCache || typeof __portalsCache !== 'object') {
- __portalsCache = {};
- }
-
- // Simple slug generator
- let base = 'portal-' + Math.random().toString(36).slice(2, 8);
- let slug = base;
- let i = 1;
- while (__portalsCache[slug]) {
- slug = `${base}-${i++}`;
- }
-
- __portalsCache[slug] = {
- label: 'New client portal',
- folder: '',
- clientEmail: '',
- uploadOnly: true,
- allowDownload: false,
- expiresAt: ''
- };
-
- loadClientPortalsList(true);
-}
-
-async function fetchPortalSubmissions(slug) {
- const res = await fetch('/api/pro/portals/submissions.php?slug=' + encodeURIComponent(slug), {
- credentials: 'include',
- headers: {
- 'X-CSRF-Token': window.csrfToken || ''
- }
- });
- const data = await safeJson(res);
- if (!data || data.success === false) {
- throw new Error((data && data.error) || 'Failed to load submissions');
- }
- const submissions = Array.isArray(data.submissions) ? data.submissions : [];
- return submissions;
-}
-
-function renderPortalSubmissionsList(listEl, countEl, submissions) {
- listEl.textContent = '';
-
- if (!Array.isArray(submissions) || submissions.length === 0) {
- countEl.textContent = 'No submissions';
- const empty = document.createElement('div');
- empty.className = 'portal-submissions-item portal-submissions-empty';
- empty.textContent = 'No submissions yet.';
- listEl.appendChild(empty);
- return;
- }
-
- countEl.textContent = submissions.length === 1
- ? '1 submission'
- : submissions.length + ' submissions';
-
- submissions.forEach(sub => {
- const item = document.createElement('div');
- item.className = 'portal-submissions-item';
-
- // -------- Line 1: date • Folder • Submitted by • IP --------
- const header = document.createElement('div');
- header.className = 'portal-submissions-header';
-
- const headerParts = [];
-
- // Date (supports createdAt, created_at, timestamp, time)
- const created = sub.createdAt || sub.created_at || sub.timestamp || sub.time;
- if (created) {
- try {
- const d = typeof created === 'number'
- ? new Date(created * 1000)
- : new Date(created);
-
- if (!isNaN(d.getTime())) {
- headerParts.push(d.toLocaleString(undefined, {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit'
- }));
- }
- } catch {
- headerParts.push(String(created));
- }
- }
-
- // We try both top-level and raw payload, so this works with:
- // {
- // "slug": "...",
- // "portalLabel": "...",
- // "folder": "test",
- // "form": {...},
- // "submittedBy": "admin",
- // "ip": "1.2.3.4",
- // ...
- // }
- const raw = sub.raw || sub;
- const folder = sub.folder || (raw && raw.folder) || '';
- const submittedBy = sub.submittedBy || (raw && raw.submittedBy) || '';
- const ip = sub.ip || (raw && raw.ip) || '';
-
- if (folder) {
- headerParts.push('Folder: ' + folder);
- }
- if (submittedBy) {
- headerParts.push('Submitted by: ' + submittedBy);
- }
- if (ip) {
- headerParts.push('IP: ' + ip);
- }
-
- header.textContent = headerParts.join(' • ');
-
- // -------- Line 2: Name • Email • Ref • Notes --------
- const summary = document.createElement('div');
- summary.className = 'portal-submissions-summary';
-
- // Prefer form fields if present
- const form = raw.form || sub.form || raw;
-
- const summaryParts = [];
- const name = form.name || sub.name || '';
- const email = form.email || sub.email || '';
- const ref = form.reference || form.ref || sub.reference || sub.ref || '';
- const notes = form.notes || form.message || sub.notes || sub.message || '';
-
- if (name) summaryParts.push('Name: ' + name);
- if (email) summaryParts.push('Email: ' + email);
- if (ref) summaryParts.push('Ref: ' + ref);
- if (notes) summaryParts.push('Notes: ' + notes);
-
- summary.textContent = summaryParts.join(' • ');
-
- item.appendChild(header);
- if (summaryParts.length) {
- item.appendChild(summary);
- }
-
- listEl.appendChild(item);
- });
-}
-
-function attachPortalSubmissionsUI() {
- const body = document.getElementById('clientPortalsBody');
- if (!body) return;
-
- body.querySelectorAll('.portal-card').forEach(card => {
- // Don't double-build if we reload the list
- if (card.querySelector('.portal-submissions-block')) {
- return;
- }
-
- const slug = card.getAttribute('data-portal-slug') || '';
- if (!slug) return;
-
- const container = document.createElement('div');
- container.className = 'portal-submissions-block';
-
- const headerRow = document.createElement('div');
- headerRow.className = 'd-flex align-items-center justify-content-between mb-1';
-
- const title = document.createElement('strong');
- title.textContent = 'Submissions';
-
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'btn btn-sm btn-outline-secondary';
- btn.textContent = 'Load submissions';
- btn.setAttribute('data-portal-action', 'load-submissions');
-
- headerRow.appendChild(title);
- headerRow.appendChild(btn);
- container.appendChild(headerRow);
-
- const countEl = document.createElement('small');
- countEl.className = 'text-muted portal-submissions-count';
- countEl.textContent = 'No submissions';
- container.appendChild(countEl);
-
- const listEl = document.createElement('div');
- listEl.className = 'portal-submissions-list';
- container.appendChild(listEl);
-
- const bodyEl = card.querySelector('.portal-card-body') || card;
- bodyEl.appendChild(container);
-
- // Shared loader for this card (used by button + initial auto-load)
- const loadSubmissions = async () => {
- countEl.textContent = 'Loading...';
- listEl.textContent = '';
-
- try {
- const submissions = await fetchPortalSubmissions(slug);
- renderPortalSubmissionsList(listEl, countEl, submissions);
- } catch (err) {
- console.error(err);
- countEl.textContent = 'Error loading submissions';
- showToast('Error loading submissions: ' + (err && err.message ? err.message : err));
- }
- };
-
- // Button = manual refresh
- btn.addEventListener('click', loadSubmissions);
-
- // Auto-load immediately when the card is attached
- loadSubmissions();
- });
-}
-
-async function saveClientPortalsFromUI(modal) {
- const body = document.getElementById('clientPortalsBody');
- const status = document.getElementById('clientPortalsStatus');
- if (!body) return;
-
- const cards = body.querySelectorAll('.card[data-portal-slug]');
- const portals = {};
-
- cards.forEach(card => {
- const origSlug = card.getAttribute('data-portal-slug') || '';
- let slug = origSlug.trim();
-
- const getVal = (selector) => {
- const el = card.querySelector(selector);
- return el ? el.value || '' : '';
- };
-
- const label = getVal('[data-portal-field="label"]').trim();
- const folder = getVal('[data-portal-field="folder"]').trim();
- const clientEmail = getVal('[data-portal-field="clientEmail"]').trim();
- const expiresAt = getVal('[data-portal-field="expiresAt"]').trim();
- const title = getVal('[data-portal-field="title"]').trim();
- const introText = getVal('[data-portal-field="introText"]').trim();
-
- const brandColor = getVal('[data-portal-field="brandColor"]').trim();
- const footerText = getVal('[data-portal-field="footerText"]').trim();
- const defName = getVal('[data-portal-field="defName"]').trim();
- const defEmail = getVal('[data-portal-field="defEmail"]').trim();
- const defRef = getVal('[data-portal-field="defRef"]').trim();
- const defNotes = getVal('[data-portal-field="defNotes"]').trim();
-
- const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
- const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]');
- const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
-
- const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
- const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
- const requireForm = requireFormEl ? !!requireFormEl.checked : false;
- const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
- const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
- const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
- const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]');
-
- const reqName = reqNameEl ? !!reqNameEl.checked : false;
- const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false;
- const reqRef = reqRefEl ? !!reqRefEl.checked : false;
- const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
-
- const slugInput = card.querySelector('[data-portal-field="slug"]');
- if (slugInput) {
- const rawSlug = slugInput.value.trim();
- if (rawSlug) slug = rawSlug;
- }
-
- if (!slug || !folder) {
- // Skip incomplete portals (or show an error if you prefer)
- return;
- }
-
- portals[slug] = {
- label,
- folder,
- clientEmail,
- uploadOnly,
- allowDownload,
- expiresAt,
- title,
- introText,
- requireForm,
- brandColor,
- footerText,
- formDefaults: {
- name: defName,
- email: defEmail,
- reference: defRef,
- notes: defNotes
- },
- formRequired: {
- name: reqName,
- email: reqEmail,
- reference: reqRef,
- notes: reqNotes
- }
- };
-
- });
-
- if (status) {
- status.textContent = 'Saving…';
- status.className = 'small text-muted';
- }
-
- try {
- const res = await saveAllPortals(portals);
- if (!res || res.success !== true) {
- throw new Error(res && res.error ? res.error : 'Unknown error saving client portals');
- }
- __portalsCache = portals;
- if (status) {
- status.textContent = 'Saved.';
- status.className = 'small text-success';
- }
- showToast('Client portals saved.');
- } catch (e) {
- console.error(e);
- if (status) {
- status.textContent = 'Error saving.';
- status.className = 'small text-danger';
- }
- showToast('Error saving client portals: ' + (e.message || e));
- }
-}
-
let __groupsCache = {};
async function openUserGroupsModal() {
diff --git a/public/js/adminPanelStyles.js b/public/js/adminPanelStyles.js
deleted file mode 100644
index 9109811..0000000
--- a/public/js/adminPanelStyles.js
+++ /dev/null
@@ -1,302 +0,0 @@
-// Admin panel inline CSS moved out of adminPanel.js
-// This file is imported for its side effects only.
-
-(function () {
- if (document.getElementById('adminPanelStyles')) return;
- const style = document.createElement('style');
- style.id = 'adminPanelStyles';
- style.textContent = `
- /* Modal sizing */
- #adminPanelModal .modal-content {
- max-width: 1100px;
- width: 50%;
- background: #fff !important;
- color: #000 !important;
- border: 1px solid #ccc !important;
- }
- @media (max-width: 900px) {
- #adminPanelModal .modal-content {
- width: 100%;
- max-width: 100%;
- }
- }
- @media (max-width: 768px) {
- #adminPanelModal .modal-content {
- width: 100%;
- max-width: 100%;
- border-radius: 0;
- height: 100%;
- }
- }
-
- /* Modal header */
- #adminPanelModal .modal-header {
- border-bottom: 1px solid rgba(0,0,0,0.15);
- padding: 0.75rem 1rem;
- align-items: center;
- }
- #adminPanelModal .modal-title {
- font-size: 1rem;
- font-weight: 600;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- }
- #adminPanelModal .modal-title .admin-title-badge {
- font-size: 0.75rem;
- font-weight: 500;
- padding: 0.1rem 0.4rem;
- border-radius: 999px;
- border: 1px solid rgba(0,0,0,0.12);
- background: rgba(0,0,0,0.03);
- }
-
- /* Modal body layout */
- #adminPanelModal .modal-body {
- display: flex;
- gap: 1rem;
- padding: 0.75rem 1rem 1rem;
- align-items: flex-start;
- }
- @media (max-width: 768px) {
- #adminPanelModal .modal-body {
- flex-direction: column;
- }
- }
-
- /* Sidebar nav */
- #adminPanelSidebar {
- width: 220px;
- max-width: 220px;
- padding-right: 0.75rem;
- border-right: 1px solid rgba(0,0,0,0.08);
- }
- @media (max-width: 768px) {
- #adminPanelSidebar {
- width: 100%;
- max-width: 100%;
- border-right: none;
- border-bottom: 1px solid rgba(0,0,0,0.08);
- padding-bottom: 0.5rem;
- margin-bottom: 0.5rem;
- }
- }
- #adminPanelSidebar .nav {
- flex-direction: column;
- gap: 0.25rem;
- }
- #adminPanelSidebar .nav-link {
- border-radius: 0.5rem;
- padding: 0.35rem 0.6rem;
- font-size: 0.85rem;
- display: flex;
- align-items: center;
- gap: 0.4rem;
- border: 1px solid transparent;
- color: #333;
- }
- #adminPanelSidebar .nav-link .material-icons {
- font-size: 1rem;
- }
- #adminPanelSidebar .nav-link.active {
- background: rgba(0, 123, 255, 0.08);
- border-color: rgba(0, 123, 255, 0.3);
- color: #0056b3;
- }
- #adminPanelSidebar .nav-link:hover {
- background: rgba(0,0,0,0.03);
- }
-
- /* Content area */
- #adminPanelContent {
- flex: 1;
- min-width: 0;
- }
-
- .admin-section-title {
- font-size: 0.95rem;
- font-weight: 600;
- margin-bottom: 0.35rem;
- display: flex;
- align-items: center;
- gap: 0.35rem;
- }
- .admin-section-title .material-icons {
- font-size: 1rem;
- }
- .admin-section-subtitle {
- font-size: 0.8rem;
- color: rgba(0,0,0,0.6);
- margin-bottom: 0.75rem;
- }
-
- .admin-field-group {
- margin-bottom: 0.9rem;
- }
- .admin-field-group label {
- font-size: 0.8rem;
- font-weight: 500;
- margin-bottom: 0.2rem;
- }
- .admin-field-group small {
- font-size: 0.75rem;
- color: rgba(0,0,0,0.6);
- }
-
- .admin-inline-actions {
- display: flex;
- gap: 0.35rem;
- flex-wrap: wrap;
- align-items: center;
- margin-top: 0.25rem;
- }
-
- .admin-badge {
- display: inline-flex;
- align-items: center;
- gap: 0.3rem;
- border-radius: 999px;
- padding: 0.1rem 0.5rem;
- font-size: 0.7rem;
- background: rgba(0,0,0,0.03);
- border: 1px solid rgba(0,0,0,0.08);
- }
- .admin-badge .material-icons {
- font-size: 0.9rem;
- }
-
- /* Tables */
- .admin-table-sm {
- font-size: 0.8rem;
- margin-bottom: 0.75rem;
- }
- .admin-table-sm th,
- .admin-table-sm td {
- padding: 0.35rem 0.4rem !important;
- vertical-align: middle;
- }
-
- /* Switch alignment */
- .form-check.form-switch .form-check-input {
- cursor: pointer;
- }
-
- /* Pro license textarea */
- #proLicenseInput {
- font-family: var(--filr-font-mono, monospace);
- font-size: 0.75rem;
- min-height: 80px;
- resize: vertical;
- }
-
- /* Pro info alert */
- #proLicenseStatus {
- font-size: 0.8rem;
- padding: 0.4rem 0.6rem;
- margin-bottom: 0.4rem;
- }
-
- /* Client portals */
- #clientPortalsBody .portal-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 0.75rem;
- padding: 0.35rem 0;
- border-bottom: 1px solid rgba(0,0,0,0.04);
- }
- #clientPortalsBody .portal-row:last-child {
- border-bottom: none;
- }
- #clientPortalsBody .portal-meta {
- font-size: 0.75rem;
- color: rgba(0,0,0,0.7);
- }
- #clientPortalsBody .portal-actions {
- display: flex;
- gap: 0.25rem;
- flex-wrap: wrap;
- justify-content: flex-end;
- }
-
- /* Submissions list */
- #clientPortalsBody .portal-submissions {
- margin-top: 0.25rem;
- padding-top: 0.25rem;
- border-top: 1px dashed rgba(0,0,0,0.08);
- }
- #clientPortalsBody .portal-submissions-title {
- font-size: 0.75rem;
- font-weight: 600;
- margin-bottom: 0.1rem;
- opacity: 0.8;
- }
- #clientPortalsBody .portal-submissions-empty {
- font-size: 0.75rem;
- font-style: italic;
- opacity: 0.6;
- }
- #clientPortalsBody .portal-submissions-item {
- font-size: 0.75rem;
- padding: 0.15rem 0;
- border-bottom: 1px solid rgba(0,0,0,0.05);
- }
- #clientPortalsBody .portal-submissions-item:last-child {
- border-bottom: none;
- }
- #clientPortalsBody .portal-submissions-meta {
- opacity: 0.75;
- font-size: 0.75rem;
- }
-
- /* Dark mode overrides */
- .dark-mode #adminPanelModal .modal-content {
- background: #121212 !important;
- color: #f5f5f5 !important;
- border-color: rgba(255,255,255,0.15) !important;
- }
- .dark-mode #adminPanelModal .modal-header {
- border-bottom-color: rgba(255,255,255,0.15);
- }
- .dark-mode #adminPanelSidebar {
- border-right-color: rgba(255,255,255,0.12);
- }
- .dark-mode #adminPanelSidebar .nav-link {
- color: #f5f5f5;
- }
- .dark-mode #adminPanelSidebar .nav-link:hover {
- background: rgba(255,255,255,0.04);
- }
- .dark-mode #adminPanelSidebar .nav-link.active {
- background: rgba(13,110,253,0.3);
- border-color: rgba(13,110,253,0.7);
- color: #fff;
- }
- .dark-mode .admin-section-subtitle {
- color: rgba(255,255,255,0.6);
- }
- .dark-mode .admin-field-group small {
- color: rgba(255,255,255,0.6);
- }
- .dark-mode .admin-badge {
- background: rgba(255,255,255,0.04);
- border-color: rgba(255,255,255,0.12);
- }
- .dark-mode .admin-table-sm tbody tr:hover td {
- background: rgba(255,255,255,0.02);
- }
- .dark-mode #clientPortalsBody .portal-row {
- border-bottom-color: rgba(255,255,255,0.08);
- }
- .dark-mode #clientPortalsBody .portal-meta {
- color: rgba(255,255,255,0.7);
- }
- .dark-mode #clientPortalsBody .portal-submissions {
- border-top-color: rgba(255,255,255,0.12);
- }
- .dark-mode #clientPortalsBody .portal-submissions-empty {
- color: rgba(255,255,255,0.5);
- }
- `;
- document.head.appendChild(style);
- })();
\ No newline at end of file
diff --git a/public/js/adminPortals.js b/public/js/adminPortals.js
new file mode 100644
index 0000000..908f0c5
--- /dev/null
+++ b/public/js/adminPortals.js
@@ -0,0 +1,1574 @@
+import { t } from './i18n.js?v={{APP_QVER}}';
+import { showToast } from './domUtils.js?v={{APP_QVER}}';
+
+// ─────────────────────────────
+// Portal intake presets
+// ─────────────────────────────
+const PORTAL_INTAKE_PRESETS = {
+ legal: {
+ label: 'Legal intake',
+ title: 'Secure legal document upload',
+ introText:
+ 'Upload engagement letters, signed agreements, IDs, and supporting documents here. ' +
+ 'Please avoid emailing sensitive files.',
+ footerText:
+ 'If you uploaded something in error, contact our office. Please do not share this link.',
+ brandColor: '#2563eb',
+ requireForm: true,
+ formVisible: {
+ name: true,
+ email: true,
+ reference: true,
+ notes: true,
+ },
+ formLabels: {
+ name: 'Full legal name',
+ email: 'Email address',
+ reference: 'Matter / case #',
+ notes: 'Notes for our team',
+ },
+ formDefaults: {
+ name: '',
+ email: '',
+ reference: '',
+ notes: '',
+ },
+ formRequired: {
+ name: true,
+ email: true,
+ reference: true,
+ notes: false,
+ },
+ },
+
+ tax: {
+ label: 'Tax client',
+ title: 'Tax documents upload',
+ introText:
+ 'Upload your tax documents (W-2s, 1099s, statements, prior returns, etc.). ' +
+ 'Please avoid emailing sensitive files.',
+ footerText:
+ 'If you are unsure what to upload, contact our office before sending files.',
+ brandColor: '#16a34a',
+ requireForm: true,
+ formVisible: {
+ name: true,
+ email: true,
+ reference: true,
+ notes: true,
+ },
+ formLabels: {
+ name: 'Name (as on tax return)',
+ email: 'Contact email',
+ reference: 'Tax year(s)',
+ notes: 'Notes / special situations',
+ },
+ formDefaults: {
+ name: '',
+ email: '',
+ reference: '',
+ notes: '',
+ },
+ formRequired: {
+ name: true,
+ email: true,
+ reference: true,
+ notes: false,
+ },
+ },
+
+ order: {
+ label: 'Order / RMA',
+ title: 'Order / RMA upload',
+ introText:
+ 'Upload photos of the item, receipts, and any supporting documents for your order or return.',
+ footerText:
+ 'Include your order or RMA number so we can locate your purchase quickly.',
+ brandColor: '#eab308',
+ requireForm: true,
+ formVisible: {
+ name: true,
+ email: true,
+ reference: true,
+ notes: true,
+ },
+ formLabels: {
+ name: 'Contact name',
+ email: 'Email for updates',
+ reference: 'Order # / RMA #',
+ notes: 'Describe the issue / reason for return',
+ },
+ formDefaults: {
+ name: '',
+ email: '',
+ reference: '',
+ notes: '',
+ },
+ formRequired: {
+ name: false,
+ email: true,
+ reference: true,
+ notes: true,
+ },
+ },
+};
+
+// Tiny JSON helper (same behavior as in adminPanel.js)
+async function safeJson(res) {
+ const text = await res.text();
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ }
+ if (!res.ok) {
+ const msg =
+ (body && (body.error || body.message)) ||
+ (text && text.trim()) ||
+ `HTTP ${res.status}`;
+ const err = new Error(msg);
+ err.status = res.status;
+ throw err;
+ }
+ return body ?? {};
+}
+
+// Reusable custom confirm using #customConfirmModal from index.html
+function portalConfirm(message) {
+ const modal = document.getElementById('customConfirmModal');
+ const msgEl = document.getElementById('confirmMessage');
+ const yesBtn = document.getElementById('confirmYesBtn');
+ const noBtn = document.getElementById('confirmNoBtn');
+
+ // Fallback to window.confirm if modal isn't present
+ if (!modal || !msgEl || !yesBtn || !noBtn) {
+ return Promise.resolve(window.confirm(message));
+ }
+
+ msgEl.textContent = message;
+ modal.style.display = 'block';
+
+ return new Promise(resolve => {
+ const cleanup = () => {
+ modal.style.display = 'none';
+ yesBtn.removeEventListener('click', onYes);
+ noBtn.removeEventListener('click', onNo);
+ // optional: close on backdrop click
+ modal.removeEventListener('click', onBackdrop);
+ document.removeEventListener('keydown', onEsc);
+ };
+
+ const onYes = (e) => {
+ e?.preventDefault?.();
+ cleanup();
+ resolve(true);
+ };
+
+ const onNo = (e) => {
+ e?.preventDefault?.();
+ cleanup();
+ resolve(false);
+ };
+
+ const onBackdrop = (e) => {
+ if (e.target === modal) {
+ cleanup();
+ resolve(false);
+ }
+ };
+
+ const onEsc = (e) => {
+ if (e.key === 'Escape') {
+ cleanup();
+ resolve(false);
+ }
+ };
+
+ yesBtn.addEventListener('click', onYes);
+ noBtn.addEventListener('click', onNo);
+ modal.addEventListener('click', onBackdrop);
+ document.addEventListener('keydown', onEsc);
+ });
+ }
+
+async function fetchAllPortals() {
+ const res = await fetch('/api/pro/portals/list.php', {
+ credentials: 'include',
+ headers: { 'X-CSRF-Token': window.csrfToken || '' }
+ });
+ const data = await safeJson(res);
+ return data && typeof data === 'object' && data.portals && typeof data.portals === 'object'
+ ? data.portals
+ : {};
+}
+
+async function saveAllPortals(portals) {
+ const res = await fetch('/api/pro/portals/save.php', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': window.csrfToken || ''
+ },
+ body: JSON.stringify({ portals })
+ });
+ return await safeJson(res);
+}
+
+let __portalsCache = {};
+// Shared folder list for portal folder picker (reuses getFolderList.php like folderManager.js)
+let __portalFolderListLoaded = false;
+let __portalFolderOptions = [];
+
+// Cache portal submissions per slug for CSV export
+const __portalSubmissionsCache = {};
+
+async function loadPortalFolderList() {
+ if (__portalFolderListLoaded) return __portalFolderOptions;
+ try {
+ const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
+ const data = await res.json();
+ let list = data;
+
+ // Support both shapes: ["A/B", "C/D"] or [{ folder: "A/B" }, ...]
+ if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) {
+ list = list.map(it => it.folder);
+ }
+
+ __portalFolderOptions = (list || [])
+ .filter(Boolean)
+ .filter(f => f !== 'trash' && f !== 'profile_pics');
+
+ __portalFolderListLoaded = true;
+ } catch (e) {
+ console.error('Error loading portal folder list', e);
+ __portalFolderOptions = [];
+ __portalFolderListLoaded = true;
+ }
+ return __portalFolderOptions;
+}
+
+// ─────────────────────────────────────────
+// Public entry point from adminPanel.js
+// ─────────────────────────────────────────
+export async function openClientPortalsModal() {
+ 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('clientPortalsModal');
+ if (!modal) {
+ modal = document.createElement('div');
+ modal.id = 'clientPortalsModal';
+ modal.style.cssText = `
+ position:fixed; inset:0; background:${overlayBg};
+ display:flex; align-items:center; justify-content:center; z-index:3650;
+ `;
+ modal.innerHTML = `
+
+
×
+
+
Client Portals
+
+ Create upload portals that point to specific folders. Clients can upload
+ (and optionally download) files without seeing your full FileRise UI.
+
+
+
+
+ cloud_upload
+ Add portal
+
+
+
+
+
+ ${t('loading')}…
+
+
+
+ ${t('cancel')}
+ ${t('save_settings')}
+
+
+ `;
+ document.body.appendChild(modal);
+
+ document.getElementById('closeClientPortalsModal').onclick = () => (modal.style.display = 'none');
+ document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
+ document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
+ document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
+ } else {
+ modal.style.background = overlayBg;
+ const content = modal.querySelector('.modal-content');
+ if (content) {
+ content.style.background = contentBg;
+ content.style.color = contentFg;
+ content.style.border = `1px solid ${borderCol}`;
+ }
+ }
+
+ modal.style.display = 'flex';
+ await loadClientPortalsList();
+}
+
+// ─────────────────────────────────────────
+// Internal helpers – same behavior as now
+// ─────────────────────────────────────────
+
+async function loadClientPortalsList(useCacheOnly) {
+ const body = document.getElementById('clientPortalsBody');
+ const status = document.getElementById('clientPortalsStatus');
+ if (!body) return;
+
+ body.textContent = `${t('loading')}…`;
+ if (status) {
+ status.textContent = '';
+ status.className = 'small text-muted';
+ }
+
+ try {
+ let portals;
+ if (useCacheOnly && __portalsCache && Object.keys(__portalsCache).length) {
+ portals = __portalsCache;
+ } else {
+ portals = await fetchAllPortals();
+ __portalsCache = portals || {};
+ }
+
+ const slugs = Object.keys(__portalsCache).sort((a, b) => a.localeCompare(b));
+ if (!slugs.length) {
+ body.innerHTML = `No client portals defined yet. Click “Add portal” to create one.
`;
+ return;
+ }
+
+ let html = '';
+ slugs.forEach(slug => {
+ const origin = window.location.origin || '';
+ const portalPath = '/portal/' + encodeURIComponent(slug);
+ const portalUrl = origin ? origin + portalPath : portalPath;
+
+ const p = __portalsCache[slug] || {};
+ const label = p.label || slug;
+ const folder = p.folder || '';
+ const clientEmail = p.clientEmail || '';
+ const uploadOnly = !!p.uploadOnly;
+ const allowDownload = p.allowDownload !== false; // default true
+ const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
+ const brandColor = p.brandColor || '';
+ const footerText = p.footerText || '';
+
+ const formDefaults = p.formDefaults || {};
+ const formRequired = p.formRequired || {};
+ const formLabels = p.formLabels || {};
+ const formVisible = p.formVisible || {};
+
+ const uploadMaxSizeMb = typeof p.uploadMaxSizeMb === 'number'
+ ? p.uploadMaxSizeMb
+ : (p.uploadMaxSizeMb ? parseInt(p.uploadMaxSizeMb, 10) || 0 : 0);
+
+ const uploadExtWhitelist = p.uploadExtWhitelist || '';
+
+ const uploadMaxPerDay = typeof p.uploadMaxPerDay === 'number'
+ ? p.uploadMaxPerDay
+ : (p.uploadMaxPerDay ? parseInt(p.uploadMaxPerDay, 10) || 0 : 0);
+
+ const showThankYou = !!p.showThankYou;
+ const thankYouText = p.thankYouText || '';
+
+ const defName = formDefaults.name || '';
+ const defEmail = formDefaults.email || '';
+ const defRef = formDefaults.reference || '';
+ const defNotes = formDefaults.notes || '';
+
+ const lblName = formLabels.name || 'Name';
+ const lblEmail = formLabels.email || 'Email';
+ const lblRef = formLabels.reference || 'Reference / Case / Order #';
+ const lblNotes = formLabels.notes || 'Notes';
+
+ const visibleName = formVisible.name !== false;
+ const visibleEmail = formVisible.email !== false;
+ const visibleRef = formVisible.reference !== false;
+ const visibleNotes = formVisible.notes !== false;
+
+ const title = p.title || '';
+ const introText = p.introText || '';
+ const requireForm = !!p.requireForm;
+
+ html += `
+
+
+
+
+ delete
+
+
+
+
+
+ Portal slug:
+
+
+
+ Display name:
+
+
+
+
+
+
+
+
+
+
+
+ Portal title (optional):
+
+
+
+
+
+ Instructions (shown on portal page):
+
+
+
+
+
+ Require info form before upload
+
+
+
+
+
+
+ Accent color:
+
+
+
+
+
+
+ Footer text (shown at bottom of portal):
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ });
+
+ body.innerHTML = html;
+
+ // Wire collapse / expand, live label updates, etc. for each portal card
+ body.querySelectorAll('.portal-card').forEach(card => {
+ const header = card.querySelector('.portal-card-header');
+ const bodyEl = card.querySelector('.portal-card-body');
+ const caret = card.querySelector('.portal-card-caret');
+ const headerLabelEl = card.querySelector('.portal-card-header-main strong');
+ const headerSlugEl = card.querySelector('.portal-card-slug');
+ const labelInput = card.querySelector('[data-portal-field="label"]');
+ const slugInput = card.querySelector('[data-portal-field="slug"]');
+
+ if (labelInput && headerLabelEl) {
+ labelInput.addEventListener('input', () => {
+ const val = labelInput.value.trim();
+ headerLabelEl.textContent = val || '(unnamed portal)';
+ });
+ }
+
+ if (slugInput && headerSlugEl) {
+ slugInput.addEventListener('input', () => {
+ const raw = slugInput.value.trim();
+ headerSlugEl.textContent = raw || card.getAttribute('data-portal-slug') || '';
+ });
+ }
+
+ if (!header || !bodyEl) return;
+
+ const setExpanded = (expanded) => {
+ header.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+ bodyEl.style.display = expanded ? 'block' : 'none';
+ if (caret) {
+ caret.textContent = expanded ? '▾' : '▸';
+ }
+ };
+
+ setExpanded(false);
+
+ const toggle = () => {
+ const expanded = header.getAttribute('aria-expanded') === 'true';
+ setExpanded(!expanded);
+ };
+
+ header.addEventListener('click', toggle);
+ header.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggle();
+ }
+ });
+ });
+
+ // Wire delete buttons (with custom confirm modal)
+body.querySelectorAll('[data-portal-action="delete"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const card = btn.closest('.card');
+ if (!card) return;
+
+ const slug = card.getAttribute('data-portal-slug') || '';
+ const labelInput = card.querySelector('[data-portal-field="label"]');
+ const name = (labelInput && labelInput.value.trim()) || slug || 'this portal';
+
+ const ok = await portalConfirm(
+ `Delete portal "${name}"?\n\n` +
+ `Existing links for this portal will stop working once you click “Save settings”.`
+ );
+ if (!ok) return;
+
+ if (slug && __portalsCache[slug]) {
+ delete __portalsCache[slug];
+ }
+ card.remove();
+ });
+ });
+ // Keep submissions viewer working
+ attachPortalSubmissionsUI();
+ // Intake presets dropdowns
+ attachPortalPresetSelectors();
+ // Attach folder pickers (browse button / optional integration with global picker)
+ attachPortalFolderPickers();
+ // Portal logo uploaders
+ attachPortalLogoUploaders();
+
+
+ } catch (e) {
+ console.error(e);
+ body.innerHTML = `Error loading client portals.
`;
+ if (status) {
+ status.textContent = 'Error loading client portals.';
+ status.className = 'small text-danger';
+ }
+ }
+}
+
+function addEmptyPortalRow() {
+ if (!__portalsCache || typeof __portalsCache !== 'object') {
+ __portalsCache = {};
+ }
+
+ let base = 'portal-' + Math.random().toString(36).slice(2, 8);
+ let slug = base;
+ let i = 1;
+ while (__portalsCache[slug]) {
+ slug = `${base}-${i++}`;
+ }
+
+ __portalsCache[slug] = {
+ label: 'New client portal',
+ folder: '',
+ clientEmail: '',
+ uploadOnly: true,
+ allowDownload: false,
+ expiresAt: ''
+ };
+
+ loadClientPortalsList(true);
+}
+
+// ─────────────────────
+// Folder picker helpers
+// ─────────────────────
+
+function attachPortalFolderPickers() {
+ const body = document.getElementById('clientPortalsBody');
+ if (!body) return;
+
+ body.querySelectorAll('.portal-card').forEach(card => {
+ const input = card.querySelector('[data-portal-field="folder"]');
+ const browseBtn = card.querySelector('.portal-folder-browse-btn');
+ if (!input) return;
+
+ if (input.dataset._portalFolderPickerBound === '1') return;
+ input.dataset._portalFolderPickerBound = '1';
+
+ // Preferred path: if you ever add a central folder picker, use it:
+ const useNativePicker = typeof window.FileRiseFolderPicker === 'function';
+
+ const openPicker = async () => {
+ if (useNativePicker) {
+ try {
+ const folder = await window.FileRiseFolderPicker({
+ current: input.value || '',
+ mode: 'select-folder',
+ source: 'client-portals'
+ });
+ if (folder) input.value = folder;
+ return;
+ } catch (e) {
+ console.error('Folder picker error', e);
+ showToast('Could not open folder picker.');
+ return;
+ }
+ }
+
+ // Fallback: datalist built from /api/folder/getFolderList.php
+ try {
+ let datalist = document.getElementById('portalFolderList');
+ if (!datalist) {
+ datalist = document.createElement('datalist');
+ datalist.id = 'portalFolderList';
+ document.body.appendChild(datalist);
+
+ const folders = await loadPortalFolderList();
+ datalist.innerHTML = '';
+ folders.forEach(f => {
+ const opt = document.createElement('option');
+ opt.value = f;
+ datalist.appendChild(opt);
+ });
+ }
+
+ input.setAttribute('list', 'portalFolderList');
+ input.focus();
+ input.select();
+ } catch (e) {
+ console.error('Error preparing folder list', e);
+ input.focus();
+ input.select();
+ }
+ };
+
+ // Clicking or focusing the input prepares the list
+ input.addEventListener('focus', openPicker);
+ input.addEventListener('click', openPicker);
+
+ // Browse button does the same thing
+ if (browseBtn && !browseBtn.__frFolderPickerBound) {
+ browseBtn.__frFolderPickerBound = true;
+ browseBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ openPicker();
+ });
+ }
+ });
+ }
+
+ function attachPortalLogoUploaders() {
+ const body = document.getElementById('clientPortalsBody');
+ if (!body) return;
+
+ body.querySelectorAll('.portal-card').forEach(card => {
+ const uploadBtn = card.querySelector('.portal-logo-upload-btn');
+ if (!uploadBtn) return;
+ if (uploadBtn.__frLogoBound) return;
+ uploadBtn.__frLogoBound = true;
+
+ const slug = (card.getAttribute('data-portal-slug') || '').trim();
+ const logoField = card.querySelector('[data-portal-field="logoFile"]');
+
+ // Hidden file input per card
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = 'image/*';
+ fileInput.style.display = 'none';
+ card.appendChild(fileInput);
+
+ uploadBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ if (!slug) {
+ showToast('Please set a portal slug before uploading a logo.');
+ return;
+ }
+ fileInput.click();
+ });
+
+ fileInput.addEventListener('change', async () => {
+ if (!fileInput.files || !fileInput.files.length) return;
+
+ const file = fileInput.files[0];
+ const formData = new FormData();
+ formData.append('portal_logo', file);
+ formData.append('slug', slug);
+
+ try {
+ const res = await fetch('/api/pro/portals/uploadLogo.php', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'X-CSRF-Token': window.csrfToken || ''
+ },
+ body: formData
+ });
+
+ const data = await safeJson(res);
+ if (!data || data.success !== true) {
+ throw new Error(data && data.error ? data.error : 'Upload failed');
+ }
+
+ const fileName = data.fileName || data.filename || '';
+ if (logoField && fileName) {
+ logoField.value = fileName;
+ }
+
+ showToast('Portal logo uploaded.');
+ } catch (err) {
+ console.error(err);
+ showToast('Error uploading portal logo: ' + (err && err.message ? err.message : err));
+ } finally {
+ fileInput.value = '';
+ }
+ });
+ });
+ }
+
+ // ─────────────────────
+// Intake presets helpers
+// ─────────────────────
+
+function applyPresetToPortalCard(card, presetKey) {
+ const preset = PORTAL_INTAKE_PRESETS[presetKey];
+ if (!preset) return;
+
+ const setVal = (selector, value) => {
+ const el = card.querySelector(selector);
+ if (el) el.value = value != null ? String(value) : '';
+ };
+
+ const setChecked = (selector, value) => {
+ const el = card.querySelector(selector);
+ if (el) el.checked = !!value;
+ };
+
+ // Display name (admin label)
+ if (preset.label) {
+ setVal('[data-portal-field="label"]', preset.label);
+ const headerLabelEl = card.querySelector('.portal-card-header-main strong');
+ if (headerLabelEl) {
+ headerLabelEl.textContent = preset.label;
+ }
+ }
+
+ // Title / intro / footer / accent / require-form
+ setVal('[data-portal-field="title"]', preset.title || '');
+ setVal('[data-portal-field="introText"]', preset.introText || '');
+ setVal('[data-portal-field="footerText"]', preset.footerText || '');
+
+ if (preset.brandColor) {
+ setVal('[data-portal-field="brandColor"]', preset.brandColor);
+ }
+
+ setChecked('[data-portal-field="requireForm"]', !!preset.requireForm);
+
+ // Visibility toggles
+ if (preset.formVisible) {
+ setChecked('[data-portal-field="visName"]', !!preset.formVisible.name);
+ setChecked('[data-portal-field="visEmail"]', !!preset.formVisible.email);
+ setChecked('[data-portal-field="visRef"]', !!preset.formVisible.reference);
+ setChecked('[data-portal-field="visNotes"]', !!preset.formVisible.notes);
+ }
+
+ // Labels
+ if (preset.formLabels) {
+ setVal('[data-portal-field="lblName"]', preset.formLabels.name || '');
+ setVal('[data-portal-field="lblEmail"]', preset.formLabels.email || '');
+ setVal('[data-portal-field="lblRef"]', preset.formLabels.reference || '');
+ setVal('[data-portal-field="lblNotes"]', preset.formLabels.notes || '');
+ }
+
+ // Defaults
+ if (preset.formDefaults) {
+ setVal('[data-portal-field="defName"]', preset.formDefaults.name || '');
+ setVal('[data-portal-field="defEmail"]', preset.formDefaults.email || '');
+ setVal('[data-portal-field="defRef"]', preset.formDefaults.reference || '');
+ setVal('[data-portal-field="defNotes"]', preset.formDefaults.notes || '');
+ }
+
+ // Required flags
+ if (preset.formRequired) {
+ setChecked('[data-portal-field="reqName"]', !!preset.formRequired.name);
+ setChecked('[data-portal-field="reqEmail"]', !!preset.formRequired.email);
+ setChecked('[data-portal-field="reqRef"]', !!preset.formRequired.reference);
+ setChecked('[data-portal-field="reqNotes"]', !!preset.formRequired.notes);
+ }
+
+ showToast(`Applied "${preset.label}" preset.`);
+ }
+
+ function attachPortalPresetSelectors() {
+ const body = document.getElementById('clientPortalsBody');
+ if (!body) return;
+
+ body.querySelectorAll('.portal-card').forEach(card => {
+ const select = card.querySelector('.portal-intake-preset');
+ if (!select || select._frPresetBound) return;
+ select._frPresetBound = true;
+
+ select.addEventListener('change', () => {
+ const key = select.value;
+ if (!key) return;
+ applyPresetToPortalCard(card, key);
+ });
+ });
+ }
+
+// ─────────────────────
+// Submissions helpers
+// ─────────────────────
+
+async function fetchPortalSubmissions(slug) {
+ const res = await fetch('/api/pro/portals/submissions.php?slug=' + encodeURIComponent(slug), {
+ credentials: 'include',
+ headers: {
+ 'X-CSRF-Token': window.csrfToken || ''
+ }
+ });
+ const data = await safeJson(res);
+ if (!data || data.success === false) {
+ throw new Error((data && data.error) || 'Failed to load submissions');
+ }
+ const submissions = Array.isArray(data.submissions) ? data.submissions : [];
+
+ // Cache for CSV export
+ __portalSubmissionsCache[slug] = submissions;
+
+ return submissions;
+ }
+
+function renderPortalSubmissionsList(listEl, countEl, submissions) {
+ listEl.textContent = '';
+
+ if (!Array.isArray(submissions) || submissions.length === 0) {
+ countEl.textContent = 'No submissions';
+ const empty = document.createElement('div');
+ empty.className = 'portal-submissions-item portal-submissions-empty';
+ empty.textContent = 'No submissions yet.';
+ listEl.appendChild(empty);
+ return;
+ }
+
+ countEl.textContent = submissions.length === 1
+ ? '1 submission'
+ : submissions.length + ' submissions';
+
+ submissions.forEach(sub => {
+ const item = document.createElement('div');
+ item.className = 'portal-submissions-item';
+
+ const header = document.createElement('div');
+ header.className = 'portal-submissions-header';
+
+ const headerParts = [];
+
+ const created = sub.createdAt || sub.created_at || sub.timestamp || sub.time;
+ if (created) {
+ try {
+ const d = typeof created === 'number'
+ ? new Date(created * 1000)
+ : new Date(created);
+
+ if (!isNaN(d.getTime())) {
+ headerParts.push(d.toLocaleString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ }));
+ }
+ } catch {
+ headerParts.push(String(created));
+ }
+ }
+
+ const raw = sub.raw || sub;
+ const folder = sub.folder || (raw && raw.folder) || '';
+ const submittedBy = sub.submittedBy || (raw && raw.submittedBy) || '';
+ const ip = sub.ip || (raw && raw.ip) || '';
+
+ if (folder) headerParts.push('Folder: ' + folder);
+ if (submittedBy) headerParts.push('Submitted by: ' + submittedBy);
+ if (ip) headerParts.push('IP: ' + ip);
+
+ header.textContent = headerParts.join(' • ');
+
+ const summary = document.createElement('div');
+ summary.className = 'portal-submissions-summary';
+
+ const form = raw.form || sub.form || raw;
+
+ const summaryParts = [];
+ const name = form.name || sub.name || '';
+ const email = form.email || sub.email || '';
+ const ref = form.reference || form.ref || sub.reference || sub.ref || '';
+ const notes = form.notes || form.message || sub.notes || sub.message || '';
+
+ if (name) summaryParts.push('Name: ' + name);
+ if (email) summaryParts.push('Email: ' + email);
+ if (ref) summaryParts.push('Ref: ' + ref);
+ if (notes) summaryParts.push('Notes: ' + notes);
+
+ summary.textContent = summaryParts.join(' • ');
+
+ item.appendChild(header);
+ if (summaryParts.length) {
+ item.appendChild(summary);
+ }
+
+ listEl.appendChild(item);
+ });
+}
+
+function normalizeSubmissionForCsv(sub) {
+ const created = sub.createdAt || sub.created_at || sub.timestamp || sub.time || '';
+ const raw = sub.raw || sub;
+ const folder = sub.folder || (raw && raw.folder) || '';
+ const submittedBy = sub.submittedBy || (raw && raw.submittedBy) || '';
+ const ip = sub.ip || (raw && raw.ip) || '';
+
+ const form = raw.form || sub.form || raw || {};
+ const name = form.name || sub.name || '';
+ const email = form.email || sub.email || '';
+ const reference = form.reference || form.ref || sub.reference || sub.ref || '';
+ const notes = form.notes || form.message || sub.notes || sub.message || '';
+
+ return {
+ created,
+ folder,
+ submittedBy,
+ ip,
+ name,
+ email,
+ reference,
+ notes
+ };
+ }
+
+ function csvEscape(val) {
+ if (val == null) return '';
+ const str = String(val);
+ if (/[",\n\r]/.test(str)) {
+ return '"' + str.replace(/"/g, '""') + '"';
+ }
+ return str;
+ }
+
+ function exportSubmissionsToCsv(slug, submissions) {
+ if (!Array.isArray(submissions) || !submissions.length) {
+ showToast('No submissions to export.');
+ return;
+ }
+
+ const header = [
+ 'Created',
+ 'Folder',
+ 'SubmittedBy',
+ 'IP',
+ 'Name',
+ 'Email',
+ 'Reference',
+ 'Notes'
+ ];
+
+ const lines = [];
+ lines.push(header.map(csvEscape).join(','));
+
+ submissions.forEach(sub => {
+ const row = normalizeSubmissionForCsv(sub);
+ const cols = [
+ row.created,
+ row.folder,
+ row.submittedBy,
+ row.ip,
+ row.name,
+ row.email,
+ row.reference,
+ row.notes
+ ];
+ lines.push(cols.map(csvEscape).join(','));
+ });
+
+ const csv = lines.join('\r\n');
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = (slug || 'portal') + '-submissions.csv';
+
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ }, 0);
+ }
+
+function attachPortalSubmissionsUI() {
+ const body = document.getElementById('clientPortalsBody');
+ if (!body) return;
+
+ body.querySelectorAll('.portal-card').forEach(card => {
+ if (card.querySelector('.portal-submissions-block')) {
+ return;
+ }
+
+ const slug = card.getAttribute('data-portal-slug') || '';
+ if (!slug) return;
+
+ const container = document.createElement('div');
+ container.className = 'portal-submissions-block';
+
+ const headerRow = document.createElement('div');
+ headerRow.className = 'd-flex align-items-center justify-content-between mb-1';
+
+ const title = document.createElement('strong');
+ title.textContent = 'Submissions';
+
+ const buttonsWrap = document.createElement('div');
+ buttonsWrap.className = 'd-flex align-items-center';
+ buttonsWrap.style.gap = '6px';
+
+ const loadBtn = document.createElement('button');
+ loadBtn.type = 'button';
+ loadBtn.className = 'btn btn-sm btn-outline-secondary portal-submissions-load-btn';
+ loadBtn.textContent = 'Load submissions';
+ loadBtn.setAttribute('data-portal-action', 'load-submissions');
+
+ const exportBtn = document.createElement('button');
+ exportBtn.type = 'button';
+ exportBtn.className = 'btn btn-sm btn-outline-secondary portal-submissions-export-btn';
+ exportBtn.textContent = 'Export CSV';
+
+ buttonsWrap.appendChild(loadBtn);
+ buttonsWrap.appendChild(exportBtn);
+
+ headerRow.appendChild(title);
+ headerRow.appendChild(buttonsWrap);
+ container.appendChild(headerRow);
+
+ const countEl = document.createElement('small');
+ countEl.className = 'text-muted portal-submissions-count';
+ countEl.textContent = 'No submissions';
+ container.appendChild(countEl);
+
+ const listEl = document.createElement('div');
+ listEl.className = 'portal-submissions-list';
+ container.appendChild(listEl);
+
+ const bodyEl = card.querySelector('.portal-card-body') || card;
+ bodyEl.appendChild(container);
+
+ const loadSubmissions = async () => {
+ countEl.textContent = 'Loading...';
+ listEl.textContent = '';
+
+ try {
+ const submissions = await fetchPortalSubmissions(slug);
+ renderPortalSubmissionsList(listEl, countEl, submissions);
+ return submissions;
+ } catch (err) {
+ console.error(err);
+ countEl.textContent = 'Error loading submissions';
+ showToast('Error loading submissions: ' + (err && err.message ? err.message : err));
+ return [];
+ }
+ };
+
+ loadBtn.addEventListener('click', () => {
+ loadSubmissions();
+ });
+
+ exportBtn.addEventListener('click', async () => {
+ let submissions = __portalSubmissionsCache[slug];
+
+ // If we don't have anything cached yet, load them first
+ if (!submissions || !submissions.length) {
+ submissions = await loadSubmissions();
+ }
+
+ if (!submissions || !submissions.length) {
+ showToast('No submissions to export yet.');
+ return;
+ }
+
+ exportSubmissionsToCsv(slug, submissions);
+ });
+
+ // Initial auto-load so the admin sees something right away
+ loadSubmissions();
+ });
+}
+
+// ─────────────────────
+// Save portals
+// ─────────────────────
+
+async function saveClientPortalsFromUI() {
+ const body = document.getElementById('clientPortalsBody');
+ const status = document.getElementById('clientPortalsStatus');
+ if (!body) return;
+
+ const cards = body.querySelectorAll('.card[data-portal-slug]');
+ const portals = {};
+
+ cards.forEach(card => {
+ const origSlug = card.getAttribute('data-portal-slug') || '';
+ let slug = origSlug.trim();
+
+ const getVal = (selector) => {
+ const el = card.querySelector(selector);
+ return el ? el.value || '' : '';
+ };
+
+ const label = getVal('[data-portal-field="label"]').trim();
+ const folder = getVal('[data-portal-field="folder"]').trim();
+ const clientEmail = getVal('[data-portal-field="clientEmail"]').trim();
+ const expiresAt = getVal('[data-portal-field="expiresAt"]').trim();
+ const title = getVal('[data-portal-field="title"]').trim();
+ const introText = getVal('[data-portal-field="introText"]').trim();
+
+ const brandColor = getVal('[data-portal-field="brandColor"]').trim();
+ const footerText = getVal('[data-portal-field="footerText"]').trim();
+ const logoFile = getVal('[data-portal-field="logoFile"]').trim();
+ const logoUrl = getVal('[data-portal-field="logoUrl"]').trim(); // (optional, not exposed in UI yet)
+
+ const defName = getVal('[data-portal-field="defName"]').trim();
+ const defEmail = getVal('[data-portal-field="defEmail"]').trim();
+ const defRef = getVal('[data-portal-field="defRef"]').trim();
+ const defNotes = getVal('[data-portal-field="defNotes"]').trim();
+
+ const lblName = getVal('[data-portal-field="lblName"]').trim();
+ const lblEmail = getVal('[data-portal-field="lblEmail"]').trim();
+ const lblRef = getVal('[data-portal-field="lblRef"]').trim();
+ const lblNotes = getVal('[data-portal-field="lblNotes"]').trim();
+
+ const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
+ const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]');
+ const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
+
+ const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
+ const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
+ const requireForm = requireFormEl ? !!requireFormEl.checked : false;
+ const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
+ const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
+ const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
+ const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]');
+
+ const reqName = reqNameEl ? !!reqNameEl.checked : false;
+ const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false;
+ const reqRef = reqRefEl ? !!reqRefEl.checked : false;
+ const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
+
+ const visNameEl = card.querySelector('[data-portal-field="visName"]');
+ const visEmailEl = card.querySelector('[data-portal-field="visEmail"]');
+ const visRefEl = card.querySelector('[data-portal-field="visRef"]');
+ const visNotesEl = card.querySelector('[data-portal-field="visNotes"]');
+
+ const visName = visNameEl ? !!visNameEl.checked : true;
+ const visEmail = visEmailEl ? !!visEmailEl.checked : true;
+ const visRef = visRefEl ? !!visRefEl.checked : true;
+ const visNotes = visNotesEl ? !!visNotesEl.checked : true;
+
+ const uploadMaxSizeMb = getVal('[data-portal-field="uploadMaxSizeMb"]').trim();
+ const uploadExtWhitelist = getVal('[data-portal-field="uploadExtWhitelist"]').trim();
+ const uploadMaxPerDay = getVal('[data-portal-field="uploadMaxPerDay"]').trim();
+ const thankYouText = getVal('[data-portal-field="thankYouText"]').trim();
+
+ const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
+ const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
+
+ const slugInput = card.querySelector('[data-portal-field="slug"]');
+ if (slugInput) {
+ const rawSlug = slugInput.value.trim();
+ if (rawSlug) slug = rawSlug;
+ }
+
+ if (!slug || !folder) {
+ return;
+ }
+
+ portals[slug] = {
+ label,
+ folder,
+ clientEmail,
+ uploadOnly,
+ allowDownload,
+ expiresAt,
+ title,
+ introText,
+ requireForm,
+ brandColor,
+ footerText,
+ logoFile,
+ logoUrl,
+ formDefaults: {
+ name: defName,
+ email: defEmail,
+ reference: defRef,
+ notes: defNotes
+ },
+ formRequired: {
+ name: reqName,
+ email: reqEmail,
+ reference: reqRef,
+ notes: reqNotes
+ },
+ formLabels: {
+ name: lblName,
+ email: lblEmail,
+ reference: lblRef,
+ notes: lblNotes
+ },
+ formVisible: {
+ name: visName,
+ email: visEmail,
+ reference: visRef,
+ notes: visNotes
+ },
+ uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
+ uploadExtWhitelist,
+ uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
+ showThankYou,
+ thankYouText,
+ };
+ });
+
+ if (status) {
+ status.textContent = 'Saving…';
+ status.className = 'small text-muted';
+ }
+
+ try {
+ const res = await saveAllPortals(portals);
+ if (!res || res.success !== true) {
+ throw new Error(res && res.error ? res.error : 'Unknown error saving client portals');
+ }
+ __portalsCache = portals;
+ if (status) {
+ status.textContent = 'Saved.';
+ status.className = 'small text-success';
+ }
+ showToast('Client portals saved.');
+
+ // Re-render from cache so headers / slugs / etc. all reflect the saved state
+ await loadClientPortalsList(true);
+ } catch (e) {
+ console.error(e);
+ if (status) {
+ status.textContent = 'Error saving.';
+ status.className = 'small text-danger';
+ }
+ showToast('Error saving client portals: ' + (e.message || e));
+ }
+}
\ No newline at end of file
diff --git a/public/js/adminSponsor.js b/public/js/adminSponsor.js
new file mode 100644
index 0000000..8f0344c
--- /dev/null
+++ b/public/js/adminSponsor.js
@@ -0,0 +1,118 @@
+// public/js/adminSponsor.js
+import { t } from './i18n.js?v={{APP_QVER}}';
+import { showToast } from './domUtils.js?v={{APP_QVER}}';
+
+// Tiny "translate with fallback" helper, same as in adminPanel.js
+const tf = (key, fallback) => {
+ const v = t(key);
+ return (v && v !== key) ? v : fallback;
+};
+
+const SPONSOR_GH = 'https://github.com/sponsors/error311';
+const SPONSOR_KOFI = 'https://ko-fi.com/error311';
+
+/**
+ * Initialize the Sponsor / Donations section inside the Admin Panel.
+ * Safe to call multiple times; it no-ops after the first run.
+ */
+export function initAdminSponsorSection() {
+ const container = document.getElementById('sponsorContent');
+ if (!container) return;
+
+ // Avoid double-wiring if initAdminSponsorSection gets called again
+ if (container.__sponsorInited) return;
+ container.__sponsorInited = true;
+
+ container.innerHTML = `
+
+
+
+
+
+ ${tf("sponsor_note_fixed", "Please consider supporting ongoing development.")}
+
+ `;
+
+ const ghInput = document.getElementById('sponsorGitHub');
+ const kfInput = document.getElementById('sponsorKoFi');
+ const copyGhBtn = document.getElementById('copySponsorGitHub');
+ const copyKfBtn = document.getElementById('copySponsorKoFi');
+ const openGh = document.getElementById('openSponsorGitHub');
+ const openKf = document.getElementById('openSponsorKoFi');
+
+ if (openGh) openGh.href = SPONSOR_GH;
+ if (openKf) openKf.href = SPONSOR_KOFI;
+
+ async function copyToClipboardSafe(text) {
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ ta.remove();
+ }
+ showToast(tf("copied", "Copied!"));
+ } catch {
+ showToast(tf("copy_failed", "Could not copy. Please copy manually."));
+ }
+ }
+
+ if (copyGhBtn && ghInput) {
+ copyGhBtn.addEventListener('click', () => copyToClipboardSafe(ghInput.value));
+ }
+ if (copyKfBtn && kfInput) {
+ copyKfBtn.addEventListener('click', () => copyToClipboardSafe(kfInput.value));
+ }
+}
\ No newline at end of file
diff --git a/public/js/filePreview.js b/public/js/filePreview.js
index 78d49e1..fe90862 100644
--- a/public/js/filePreview.js
+++ b/public/js/filePreview.js
@@ -9,6 +9,44 @@ export function buildPreviewUrl(folder, name) {
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
+const MEDIA_VOLUME_KEY = 'frMediaVolume';
+const MEDIA_MUTED_KEY = 'frMediaMuted';
+
+function loadSavedMediaVolume(el) {
+ if (!el) return;
+ try {
+ const v = localStorage.getItem(MEDIA_VOLUME_KEY);
+ if (v !== null) {
+ const vol = parseFloat(v);
+ if (!Number.isNaN(vol)) {
+ el.volume = Math.max(0, Math.min(1, vol));
+ }
+ }
+ const m = localStorage.getItem(MEDIA_MUTED_KEY);
+ if (m !== null) {
+ el.muted = (m === '1');
+ }
+ } catch {
+ // ignore storage errors
+ }
+}
+
+function attachVolumePersistence(el) {
+ if (!el) return;
+ try {
+ el.addEventListener('volumechange', () => {
+ try {
+ localStorage.setItem(MEDIA_VOLUME_KEY, String(el.volume));
+ localStorage.setItem(MEDIA_MUTED_KEY, el.muted ? '1' : '0');
+ } catch {
+ // ignore storage errors
+ }
+ });
+ } catch {
+ // ignore
+ }
+}
+
/* -------------------------------- Share modal (existing) -------------------------------- */
export function openShareModal(file, folder) {
const existing = document.getElementById("shareModal");
@@ -539,6 +577,10 @@ export function previewFile(fileUrl, fileName) {
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
+
+ // Apply last-used volume/mute, and persist future changes
+ loadSavedMediaVolume(video);
+ attachVolumePersistence(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
@@ -735,6 +777,11 @@ export function previewFile(fileUrl, fileName) {
audio.className = "audio-modal";
audio.style.maxWidth = "88vw";
container.appendChild(audio);
+
+ // Share the same volume/mute behavior with videos
+ loadSavedMediaVolume(audio);
+ attachVolumePersistence(audio);
+
overlay.style.display = "flex";
} else {
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
diff --git a/public/js/portal-login.js b/public/js/portal-login.js
index 8fda906..d4ed918 100644
--- a/public/js/portal-login.js
+++ b/public/js/portal-login.js
@@ -218,6 +218,7 @@ function getRedirectTarget() {
const headingEl = document.getElementById('portalLoginTitle');
const subtitleEl = document.getElementById('portalLoginSubtitle');
const footerEl = document.getElementById('portalLoginFooter');
+ const logoEl = document.getElementById('portalLoginLogo');
if (headingEl) {
headingEl.textContent = 'Sign in to ' + title;
@@ -237,6 +238,24 @@ function getRedirectTarget() {
footerEl.style.display = 'none';
}
}
+
+ // 🔹 Portal logo: use logoFile from metadata if present
+ if (logoEl) {
+ let logoSrc = null;
+
+ // If you ever decide to store a direct URL:
+ if (portal.logoUrl && portal.logoUrl.trim()) {
+ logoSrc = portal.logoUrl.trim();
+ } else if (portal.logoFile && portal.logoFile.trim()) {
+ // Same convention as portal.html: files live in uploads/profile_pics
+ logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
+ }
+
+ if (logoSrc) {
+ logoEl.src = logoSrc;
+ logoEl.alt = title;
+ }
+ }
// Document title
try {
diff --git a/public/js/portal.js b/public/js/portal.js
index 422e67e..079a791 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -30,6 +30,127 @@ function portalCanDownload() {
return true;
}
+function getPortalSlug() {
+ return portal && (portal.slug || portal.label || '') || '';
+}
+
+function normalizeExtList(raw) {
+ if (!raw) return [];
+ return String(raw)
+ .split(/[,\s]+/)
+ .map(x => x.trim().replace(/^\./, '').toLowerCase())
+ .filter(Boolean);
+}
+
+function getAllowedExts() {
+ if (!portal || !portal.uploadExtWhitelist) return [];
+ return normalizeExtList(portal.uploadExtWhitelist);
+}
+
+function getMaxSizeBytes() {
+ if (!portal || !portal.uploadMaxSizeMb) return 0;
+ const n = parseInt(portal.uploadMaxSizeMb, 10);
+ if (!n || n <= 0) return 0;
+ return n * 1024 * 1024;
+}
+
+// Simple per-browser-per-day counter; not true IP-based.
+function applyUploadRateLimit(desiredCount) {
+ if (!portal || !portal.uploadMaxPerDay) return desiredCount;
+
+ const maxPerDay = parseInt(portal.uploadMaxPerDay, 10);
+ if (!maxPerDay || maxPerDay <= 0) return desiredCount;
+
+ const slug = getPortalSlug() || 'default';
+ const today = new Date().toISOString().slice(0, 10);
+ const key = 'portalUploadRate:' + slug;
+
+ let state = { date: today, count: 0 };
+ try {
+ const raw = localStorage.getItem(key);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && parsed.date === today && typeof parsed.count === 'number') {
+ state = parsed;
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ if (state.count >= maxPerDay) {
+ showToast('Daily upload limit reached for this portal.');
+ return 0;
+ }
+
+ const remaining = maxPerDay - state.count;
+ if (desiredCount > remaining) {
+ showToast('You can only upload ' + remaining + ' more file(s) today for this portal.');
+ return remaining;
+ }
+
+ return desiredCount;
+}
+
+function bumpUploadRateCounter(delta) {
+ if (!portal || !portal.uploadMaxPerDay || !delta) return;
+
+ const maxPerDay = parseInt(portal.uploadMaxPerDay, 10);
+ if (!maxPerDay || maxPerDay <= 0) return;
+
+ const slug = getPortalSlug() || 'default';
+ const today = new Date().toISOString().slice(0, 10);
+ const key = 'portalUploadRate:' + slug;
+
+ let state = { date: today, count: 0 };
+ try {
+ const raw = localStorage.getItem(key);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && parsed.date === today && typeof parsed.count === 'number') {
+ state = parsed.date === today ? parsed : state;
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ if (state.date !== today) {
+ state = { date: today, count: 0 };
+ }
+
+ state.count += delta;
+ if (state.count < 0) state.count = 0;
+
+ try {
+ localStorage.setItem(key, JSON.stringify(state));
+ } catch {
+ // ignore
+ }
+}
+
+function showThankYouScreen() {
+ if (!portal || !portal.showThankYou) return;
+
+ const section = qs('portalThankYouSection');
+ const msgEl = document.getElementById('portalThankYouMessage');
+ const upload = qs('portalUploadSection');
+
+ if (msgEl) {
+ const text =
+ (portal.thankYouText && portal.thankYouText.trim()) ||
+ 'Thank you. Your files have been uploaded successfully.';
+ msgEl.textContent = text;
+ }
+
+ if (section) {
+ section.style.display = 'block';
+ }
+ if (upload) {
+ upload.style.opacity = '0.3';
+ }
+}
+
// ----------------- DOM helpers / status -----------------
function qs(id) {
return document.getElementById(id);
@@ -45,6 +166,33 @@ function setStatus(msg, isError = false) {
}
}
+// ----------------- Form labels (custom captions) -----------------
+function applyPortalFormLabels() {
+ if (!portal) return;
+
+ const labels = portal.formLabels || {};
+ const required = portal.formRequired || {};
+
+ const defs = [
+ { key: 'name', forId: 'portalFormName', defaultLabel: 'Name' },
+ { key: 'email', forId: 'portalFormEmail', defaultLabel: 'Email' },
+ { key: 'reference', forId: 'portalFormReference', defaultLabel: 'Reference / Case / Order #' },
+ { key: 'notes', forId: 'portalFormNotes', defaultLabel: 'Notes' },
+ ];
+
+ defs.forEach(def => {
+ const labelEl = document.querySelector(`label[for="${def.forId}"]`);
+ if (!labelEl) return;
+
+ const base = (labels[def.key] || def.defaultLabel || '').trim() || def.defaultLabel;
+ const isRequired = !!required[def.key];
+
+ // Add a subtle "*" for required fields; skip if already added
+ const text = isRequired && !base.endsWith('*') ? `${base} *` : base;
+ labelEl.textContent = text;
+ });
+}
+
// ----------------- Form submit -----------------
async function submitPortalForm(slug, formData) {
const payload = {
@@ -109,7 +257,7 @@ async function sendRequest(url, method = 'GET', data = null, customHeaders = {})
// ----------------- Portal form wiring -----------------
function setupPortalForm(slug) {
- const formSection = qs('portalFormSection');
+ const formSection = qs('portalFormSection');
const uploadSection = qs('portalUploadSection');
if (!portal || !portal.requireForm) {
@@ -136,39 +284,103 @@ function setupPortalForm(slug) {
const notesEl = qs('portalFormNotes');
const submitBtn = qs('portalFormSubmit');
- const fd = portal.formDefaults || {};
+ const groupName = qs('portalFormGroupName');
+ const groupEmail = qs('portalFormGroupEmail');
+ const groupReference = qs('portalFormGroupReference');
+ const groupNotes = qs('portalFormGroupNotes');
- if (nameEl && fd.name && !nameEl.value) {
+ const labelName = qs('portalFormLabelName');
+ const labelEmail = qs('portalFormLabelEmail');
+ const labelReference = qs('portalFormLabelReference');
+ const labelNotes = qs('portalFormLabelNotes');
+
+ const fd = portal.formDefaults || {};
+ const labels = portal.formLabels || {};
+ const visRaw = portal.formVisible || portal.formVisibility || {};
+ const req = portal.formRequired || {};
+
+ // default: visible when not specified
+ const visible = {
+ name: visRaw.name !== false,
+ email: visRaw.email !== false,
+ reference: visRaw.reference !== false,
+ notes: visRaw.notes !== false,
+ };
+
+ // Apply labels (fallback to defaults)
+ if (labelName) labelName.textContent = labels.name || 'Name';
+ if (labelEmail) labelEmail.textContent = labels.email || 'Email';
+ if (labelReference) labelReference.textContent = labels.reference || 'Reference / Case / Order #';
+ if (labelNotes) labelNotes.textContent = labels.notes || 'Notes';
+
+ // Helper to (re)add the required star spans
+ const setStar = (labelEl, isVisible, isRequired) => {
+ if (!labelEl) return;
+ // remove any previous star
+ const old = labelEl.querySelector('.portal-required-star');
+ if (old) old.remove();
+ if (isVisible && isRequired) {
+ const s = document.createElement('span');
+ s.className = 'portal-required-star';
+ s.textContent = ' *';
+ labelEl.appendChild(s);
+ }
+ };
+
+ // Show/hide groups
+ if (groupName) groupName.style.display = visible.name ? '' : 'none';
+ if (groupEmail) groupEmail.style.display = visible.email ? '' : 'none';
+ if (groupReference) groupReference.style.display = visible.reference ? '' : 'none';
+ if (groupNotes) groupNotes.style.display = visible.notes ? '' : 'none';
+
+ // Apply stars AFTER labels and visibility
+ setStar(labelName, visible.name, !!req.name);
+ setStar(labelEmail, visible.email, !!req.email);
+ setStar(labelReference, visible.reference, !!req.reference);
+ setStar(labelNotes, visible.notes, !!req.notes);
+
+ // If literally no fields are visible, just treat as no form
+ if (!visible.name && !visible.email && !visible.reference && !visible.notes) {
+ portalFormDone = true;
+ sessionStorage.setItem(key, '1');
+ if (formSection) formSection.style.display = 'none';
+ if (uploadSection) uploadSection.style.opacity = '1';
+ return;
+ }
+
+ // Prefill defaults only for visible fields
+ if (nameEl && visible.name && fd.name && !nameEl.value) {
nameEl.value = fd.name;
}
- if (emailEl && fd.email && !emailEl.value) {
- emailEl.value = fd.email;
- } else if (emailEl && portal.clientEmail && !emailEl.value) {
- // fallback to clientEmail
- emailEl.value = portal.clientEmail;
+ if (emailEl && visible.email) {
+ if (fd.email && !emailEl.value) {
+ emailEl.value = fd.email;
+ } else if (portal.clientEmail && !emailEl.value) {
+ emailEl.value = portal.clientEmail;
+ }
}
- if (refEl && fd.reference && !refEl.value) {
+ if (refEl && visible.reference && fd.reference && !refEl.value) {
refEl.value = fd.reference;
}
- if (notesEl && fd.notes && !notesEl.value) {
+ if (notesEl && visible.notes && fd.notes && !notesEl.value) {
notesEl.value = fd.notes;
}
if (!submitBtn) return;
submitBtn.onclick = async () => {
- const name = nameEl ? nameEl.value.trim() : '';
+ const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
- const reference = refEl ? refEl.value.trim() : '';
+ const reference = refEl ? refEl.value.trim() : '';
const notes = notesEl ? notesEl.value.trim() : '';
- const req = portal.formRequired || {};
const missing = [];
- if (req.name && !name) missing.push('name');
- if (req.email && !email) missing.push('email');
- if (req.reference && !reference) missing.push('reference');
- if (req.notes && !notes) missing.push('notes');
+ // Only validate visible fields
+ if (visible.name && req.name && !name) missing.push(labels.name || 'Name');
+ if (visible.email && req.email && !email) missing.push(labels.email || 'Email');
+ if (visible.reference && req.reference && !reference) missing.push(labels.reference || 'Reference');
+ if (visible.notes && req.notes && !notes) missing.push(labels.notes || 'Notes');
if (missing.length) {
showToast('Please fill in: ' + missing.join(', ') + '.');
@@ -176,8 +388,11 @@ function setupPortalForm(slug) {
}
// default behavior when no specific required flags:
+ // at least name or email, but only if those fields are visible
if (!req.name && !req.email && !req.reference && !req.notes) {
- if (!name && !email) {
+ const hasNameField = visible.name;
+ const hasEmailField = visible.email;
+ if ((hasNameField || hasEmailField) && !name && !email) {
showToast('Please provide at least a name or email.');
return;
}
@@ -285,6 +500,7 @@ function renderPortalInfo() {
const footerEl = document.getElementById('portalFooter');
const drop = qs('portalDropzone');
const card = document.querySelector('.portal-card');
+ const logoImg = document.querySelector('.portal-logo img');
const formBtn = qs('portalFormSubmit');
const refreshBtn = qs('portalRefreshBtn');
const filesSection = qs('portalFilesSection');
@@ -303,6 +519,34 @@ function renderPortalInfo() {
const folder = portalFolder();
descEl.textContent = 'Files you upload here go directly into: ' + folder;
}
+
+ const bits = [];
+
+ if (portal.uploadMaxSizeMb) {
+ bits.push('Max file size: ' + portal.uploadMaxSizeMb + ' MB');
+ }
+
+ const exts = getAllowedExts();
+ if (exts.length) {
+ bits.push('Allowed types: ' + exts.join(', '));
+ }
+
+ if (portal.uploadMaxPerDay) {
+ bits.push('Daily upload limit: ' + portal.uploadMaxPerDay + ' file(s)');
+ }
+
+ if (bits.length) {
+ descEl.textContent += ' (' + bits.join(' • ') + ')';
+ }
+ }
+
+ if (logoImg) {
+ if (portal.logoUrl && portal.logoUrl.trim()) {
+ logoImg.src = portal.logoUrl.trim();
+ } else if (portal.logoFile && portal.logoFile.trim()) {
+ // Fallback if backend only supplies logoFile
+ logoImg.src = '/uploads/profile_pics/' + encodeURIComponent(portal.logoFile.trim());
+ }
}
if (subtitleEl) {
@@ -317,7 +561,7 @@ function renderPortalInfo() {
? portal.footerText.trim()
: '';
}
-
+ applyPortalFormLabels();
const color = portal.brandColor && portal.brandColor.trim();
if (color) {
// expose brand color as a CSS variable for gallery styling
@@ -502,7 +746,71 @@ async function uploadFiles(fileList) {
return;
}
- const files = Array.from(fileList);
+ let files = Array.from(fileList);
+ if (!files.length) return;
+
+ // 1) Filter by max size
+ const maxBytes = getMaxSizeBytes();
+ if (maxBytes > 0) {
+ const tooBigNames = [];
+ files = files.filter(f => {
+ if (f.size && f.size > maxBytes) {
+ tooBigNames.push(f.name || 'unnamed');
+ return false;
+ }
+ return true;
+ });
+ if (tooBigNames.length) {
+ showToast(
+ 'Skipped ' +
+ tooBigNames.length +
+ ' file(s) over ' +
+ portal.uploadMaxSizeMb +
+ ' MB.'
+ );
+ }
+ }
+
+ // 2) Filter by allowed extensions
+ const allowedExts = getAllowedExts();
+ if (allowedExts.length) {
+ const skipped = [];
+ files = files.filter(f => {
+ const name = f.name || '';
+ const parts = name.split('.');
+ const ext = parts.length > 1 ? parts.pop().trim().toLowerCase() : '';
+ if (!ext || !allowedExts.includes(ext)) {
+ skipped.push(name || 'unnamed');
+ return false;
+ }
+ return true;
+ });
+ if (skipped.length) {
+ showToast(
+ 'Skipped ' +
+ skipped.length +
+ ' file(s) not matching allowed types: ' +
+ allowedExts.join(', ')
+ );
+ }
+ }
+
+ if (!files.length) {
+ setStatus('No files to upload after applying portal rules.', true);
+ return;
+ }
+
+ // 3) Rate-limit per day (simple per-browser guard)
+ const requestedCount = files.length;
+ const allowedCount = applyUploadRateLimit(requestedCount);
+ if (!allowedCount) {
+ setStatus('Upload blocked by daily limit.', true);
+ return;
+ }
+ if (allowedCount < requestedCount) {
+ files = files.slice(0, allowedCount);
+ }
+
const folder = portalFolder();
setStatus('Uploading ' + files.length + ' file(s)…');
@@ -575,9 +883,19 @@ async function uploadFiles(fileList) {
showToast('Upload failed.');
}
+ // Bump local daily counter by successful uploads
+ if (successCount > 0) {
+ bumpUploadRateCounter(successCount);
+ }
+
if (portalCanDownload()) {
loadPortalFiles();
}
+
+ // Optional thank-you screen
+ if (successCount > 0 && portal.showThankYou) {
+ showThankYouScreen();
+ }
}
// ----------------- Upload UI wiring -----------------
diff --git a/public/portal-login.html b/public/portal-login.html
index f6864dd..87fac23 100644
--- a/public/portal-login.html
+++ b/public/portal-login.html
@@ -92,17 +92,19 @@
-
Loading…
-
-
diff --git a/resources/dark-client-portal1.png b/resources/dark-client-portal1.png
index a4cdca1..9c4c848 100644
Binary files a/resources/dark-client-portal1.png and b/resources/dark-client-portal1.png differ
diff --git a/resources/dark-client-portal2.png b/resources/dark-client-portal2.png
index 8b2f570..db1e132 100644
Binary files a/resources/dark-client-portal2.png and b/resources/dark-client-portal2.png differ
diff --git a/resources/dark-client-portal3.png b/resources/dark-client-portal3.png
new file mode 100644
index 0000000..2e0dc41
Binary files /dev/null and b/resources/dark-client-portal3.png differ
diff --git a/resources/dark-client-portal4.png b/resources/dark-client-portal4.png
new file mode 100644
index 0000000..2a20420
Binary files /dev/null and b/resources/dark-client-portal4.png differ
diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php
index d3a91eb..89e655e 100644
--- a/src/controllers/AdminController.php
+++ b/src/controllers/AdminController.php
@@ -314,7 +314,6 @@ public function saveProPortals(array $portalsPayload): void
throw new InvalidArgumentException('Invalid portals format.');
}
- // Minimal normalization; deeper validation can live inside ProPortals
$data = ['portals' => []];
foreach ($portalsPayload as $slug => $info) {
@@ -334,55 +333,100 @@ public function saveProPortals(array $portalsPayload): void
? !empty($info['allowDownload'])
: true;
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
-
- // Optional branding + form behavior
- $title = trim((string)($info['title'] ?? ''));
- $introText = trim((string)($info['introText'] ?? ''));
- $requireForm = !empty($info['requireForm']);
- $brandColor = trim((string)($info['brandColor'] ?? ''));
- $footerText = trim((string)($info['footerText'] ?? ''));
- $formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
- ? $info['formDefaults']
- : [];
+ // Branding + form behavior
+ $title = trim((string)($info['title'] ?? ''));
+ $introText = trim((string)($info['introText'] ?? ''));
+ $requireForm = !empty($info['requireForm']);
+ $brandColor = trim((string)($info['brandColor'] ?? ''));
+ $footerText = trim((string)($info['footerText'] ?? ''));
- // Normalize defaults for known keys
- $formDefaults = [
- 'name' => trim((string)($formDefaults['name'] ?? '')),
- 'email' => trim((string)($formDefaults['email'] ?? '')),
- 'reference' => trim((string)($formDefaults['reference'] ?? '')),
- 'notes' => trim((string)($formDefaults['notes'] ?? '')),
- ];
- $formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
- ? $info['formRequired']
- : [];
+ // Optional logo info
+ $logoFile = trim((string)($info['logoFile'] ?? ''));
+ $logoUrl = trim((string)($info['logoUrl'] ?? ''));
+
+ // Upload rules / thank-you behavior
+ $uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
+ $uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
+ $uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
+ $showThankYou = !empty($info['showThankYou']);
+ $thankYouText = trim((string)($info['thankYouText'] ?? ''));
+
+ // Form defaults
+ $formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
+ ? $info['formDefaults']
+ : [];
+
+ $formDefaults = [
+ 'name' => trim((string)($formDefaults['name'] ?? '')),
+ 'email' => trim((string)($formDefaults['email'] ?? '')),
+ 'reference' => trim((string)($formDefaults['reference'] ?? '')),
+ 'notes' => trim((string)($formDefaults['notes'] ?? '')),
+ ];
+
+ // Required flags
+ $formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
+ ? $info['formRequired']
+ : [];
+
+ $formRequired = [
+ 'name' => !empty($formRequired['name']),
+ 'email' => !empty($formRequired['email']),
+ 'reference' => !empty($formRequired['reference']),
+ 'notes' => !empty($formRequired['notes']),
+ ];
+
+ // Labels
+ $formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
+ ? $info['formLabels']
+ : [];
+
+ $formLabels = [
+ 'name' => trim((string)($formLabels['name'] ?? 'Name')),
+ 'email' => trim((string)($formLabels['email'] ?? 'Email')),
+ 'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
+ 'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
+ ];
+
+ // Visibility
+ $formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
+ ? $info['formVisible']
+ : [];
+
+ $formVisible = [
+ 'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
+ 'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
+ 'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
+ 'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
+ ];
- $formRequired = [
- 'name' => !empty($formRequired['name']),
- 'email' => !empty($formRequired['email']),
- 'reference' => !empty($formRequired['reference']),
- 'notes' => !empty($formRequired['notes']),
-];
-
if ($folder === '') {
continue;
}
-
+
$data['portals'][$slug] = [
- 'label' => $label,
- 'folder' => $folder,
- 'clientEmail' => $clientEmail,
- 'uploadOnly' => $uploadOnly,
- 'allowDownload' => $allowDownload,
- 'expiresAt' => $expiresAt,
- // NEW
- 'title' => $title,
- 'introText' => $introText,
- 'requireForm' => $requireForm,
- 'brandColor' => $brandColor,
- 'footerText' => $footerText,
- 'formDefaults' => $formDefaults,
- 'formRequired' => $formRequired,
+ 'label' => $label,
+ 'folder' => $folder,
+ 'clientEmail' => $clientEmail,
+ 'uploadOnly' => $uploadOnly,
+ 'allowDownload' => $allowDownload,
+ 'expiresAt' => $expiresAt,
+ 'title' => $title,
+ 'introText' => $introText,
+ 'requireForm' => $requireForm,
+ 'brandColor' => $brandColor,
+ 'footerText' => $footerText,
+ 'logoFile' => $logoFile,
+ 'logoUrl' => $logoUrl,
+ 'uploadMaxSizeMb' => $uploadMaxSizeMb,
+ 'uploadExtWhitelist' => $uploadExtWhitelist,
+ 'uploadMaxPerDay' => $uploadMaxPerDay,
+ 'showThankYou' => $showThankYou,
+ 'thankYouText' => $thankYouText,
+ 'formDefaults' => $formDefaults,
+ 'formRequired' => $formRequired,
+ 'formLabels' => $formLabels,
+ 'formVisible' => $formVisible,
];
}
diff --git a/src/controllers/PortalController.php b/src/controllers/PortalController.php
index af4cf8c..c978ff4 100644
--- a/src/controllers/PortalController.php
+++ b/src/controllers/PortalController.php
@@ -11,16 +11,29 @@ final class PortalController
*
* Returns:
* [
- * 'slug' => string,
- * 'label' => string,
- * 'folder' => string,
- * 'clientEmail' => string,
- * 'uploadOnly' => bool,
- * 'allowDownload' => bool,
- * 'expiresAt' => string,
- * 'title' => string,
- * 'introText' => string,
- * 'requireForm' => bool
+ * 'slug' => string,
+ * 'label' => string,
+ * 'folder' => string,
+ * 'clientEmail' => string,
+ * 'uploadOnly' => bool,
+ * 'allowDownload' => bool,
+ * 'expiresAt' => string,
+ * 'title' => string,
+ * 'introText' => string,
+ * 'requireForm' => bool,
+ * 'brandColor' => string,
+ * 'footerText' => string,
+ * 'formDefaults' => array,
+ * 'formRequired' => array,
+ * 'formLabels' => array,
+ * 'formVisible' => array,
+ * 'logoFile' => string,
+ * 'logoUrl' => string,
+ * 'uploadMaxSizeMb' => int,
+ * 'uploadExtWhitelist' => string,
+ * 'uploadMaxPerDay' => int,
+ * 'showThankYou' => bool,
+ * 'thankYouText' => string,
* ]
*/
public static function getPortalBySlug(string $slug): array
@@ -62,13 +75,14 @@ final class PortalController
: true;
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
- // NEW: optional branding + intake behavior
- $title = trim((string)($p['title'] ?? ''));
- $introText = trim((string)($p['introText'] ?? ''));
- $requireForm = !empty($p['requireForm']);
- $brandColor = trim((string)($p['brandColor'] ?? ''));
- $footerText = trim((string)($p['footerText'] ?? ''));
+ // Branding + intake behavior
+ $title = trim((string)($p['title'] ?? ''));
+ $introText = trim((string)($p['introText'] ?? ''));
+ $requireForm = !empty($p['requireForm']);
+ $brandColor = trim((string)($p['brandColor'] ?? ''));
+ $footerText = trim((string)($p['footerText'] ?? ''));
+ // Defaults / required
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
? $p['formDefaults']
: [];
@@ -79,16 +93,52 @@ final class PortalController
'reference' => trim((string)($fd['reference'] ?? '')),
'notes' => trim((string)($fd['notes'] ?? '')),
];
- $fr = isset($p['formRequired']) && is_array($p['formRequired'])
- ? $p['formRequired']
- : [];
- $formRequired = [
- 'name' => !empty($fr['name']),
- 'email' => !empty($fr['email']),
- 'reference' => !empty($fr['reference']),
- 'notes' => !empty($fr['notes']),
- ];
+ $fr = isset($p['formRequired']) && is_array($p['formRequired'])
+ ? $p['formRequired']
+ : [];
+
+ $formRequired = [
+ 'name' => !empty($fr['name']),
+ 'email' => !empty($fr['email']),
+ 'reference' => !empty($fr['reference']),
+ 'notes' => !empty($fr['notes']),
+ ];
+
+ // Optional formLabels
+ $fl = isset($p['formLabels']) && is_array($p['formLabels'])
+ ? $p['formLabels']
+ : [];
+
+ $formLabels = [
+ 'name' => trim((string)($fl['name'] ?? 'Name')),
+ 'email' => trim((string)($fl['email'] ?? 'Email')),
+ 'reference' => trim((string)($fl['reference'] ?? 'Reference / Case / Order #')),
+ 'notes' => trim((string)($fl['notes'] ?? 'Notes')),
+ ];
+
+ // Optional visibility
+ $fv = isset($p['formVisible']) && is_array($p['formVisible'])
+ ? $p['formVisible']
+ : [];
+
+ $formVisible = [
+ 'name' => !array_key_exists('name', $fv) || !empty($fv['name']),
+ 'email' => !array_key_exists('email', $fv) || !empty($fv['email']),
+ 'reference' => !array_key_exists('reference', $fv) || !empty($fv['reference']),
+ 'notes' => !array_key_exists('notes', $fv) || !empty($fv['notes']),
+ ];
+
+ // Optional per-portal logo
+ $logoFile = trim((string)($p['logoFile'] ?? ''));
+ $logoUrl = trim((string)($p['logoUrl'] ?? ''));
+
+ // Upload rules / thank-you behavior
+ $uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
+ $uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
+ $uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
+ $showThankYou = !empty($p['showThankYou']);
+ $thankYouText = trim((string)($p['thankYouText'] ?? ''));
if ($folder === '') {
throw new RuntimeException('Portal misconfigured: empty folder.');
@@ -103,21 +153,29 @@ final class PortalController
}
return [
- 'slug' => $slug,
- 'label' => $label,
- 'folder' => $folder,
- 'clientEmail' => $clientEmail,
- 'uploadOnly' => $uploadOnly,
- 'allowDownload' => $allowDownload,
- 'expiresAt' => $expiresAt,
-
- 'title' => $title,
- 'introText' => $introText,
- 'requireForm' => $requireForm,
- 'brandColor' => $brandColor,
- 'footerText' => $footerText,
- 'formDefaults' => $formDefaults,
- 'formRequired' => $formRequired,
+ 'slug' => $slug,
+ 'label' => $label,
+ 'folder' => $folder,
+ 'clientEmail' => $clientEmail,
+ 'uploadOnly' => $uploadOnly,
+ 'allowDownload' => $allowDownload,
+ 'expiresAt' => $expiresAt,
+ 'title' => $title,
+ 'introText' => $introText,
+ 'requireForm' => $requireForm,
+ 'brandColor' => $brandColor,
+ 'footerText' => $footerText,
+ 'formDefaults' => $formDefaults,
+ 'formRequired' => $formRequired,
+ 'formLabels' => $formLabels,
+ 'formVisible' => $formVisible,
+ 'logoFile' => $logoFile,
+ 'logoUrl' => $logoUrl,
+ 'uploadMaxSizeMb' => $uploadMaxSizeMb,
+ 'uploadExtWhitelist' => $uploadExtWhitelist,
+ 'uploadMaxPerDay' => $uploadMaxPerDay,
+ 'showThankYou' => $showThankYou,
+ 'thankYouText' => $thankYouText,
];
}
}
\ No newline at end of file
diff --git a/src/controllers/UserController.php b/src/controllers/UserController.php
index 92712a0..a504f50 100644
--- a/src/controllers/UserController.php
+++ b/src/controllers/UserController.php
@@ -797,6 +797,90 @@ class UserController
exit;
}
+ /**
+ * Upload a logo for a specific client portal (Pro-only; admin, CSRF).
+ * Stores the file in UPLOAD_DIR/profile_pics and returns filename + URL.
+ */
+ public function uploadPortalLogo(): void
+ {
+ self::jsonHeaders();
+
+ // Auth, admin & CSRF
+ self::requireAuth();
+ self::requireAdmin();
+ self::requireCsrf();
+
+ if (empty($_FILES['portal_logo']) || $_FILES['portal_logo']['error'] !== UPLOAD_ERR_OK) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
+ exit;
+ }
+
+ $file = $_FILES['portal_logo'];
+
+ // Optional: which portal (used only for filename prefix)
+ $slugRaw = isset($_POST['slug']) ? (string)$_POST['slug'] : '';
+ $slug = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slugRaw) ?: 'portal';
+
+ // Validate MIME & size (same rules as uploadPicture / uploadBrandLogo)
+ $allowed = [
+ 'image/jpeg' => 'jpg',
+ 'image/png' => 'png',
+ 'image/gif' => 'gif',
+ ];
+
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mime = finfo_file($finfo, $file['tmp_name']);
+ finfo_close($finfo);
+
+ if (!isset($allowed[$mime])) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'Invalid file type']);
+ exit;
+ }
+
+ if ($file['size'] > 2 * 1024 * 1024) { // 2MB
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'File too large']);
+ exit;
+ }
+
+ // Destination: reuse profile_pics directory
+ $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
+ if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
+ exit;
+ }
+
+ $ext = $allowed[$mime];
+ $filename = 'portal_' . $slug . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
+ $dest = $uploadDir . '/' . $filename;
+
+ if (!move_uploaded_file($file['tmp_name'], $dest)) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Failed to save file']);
+ exit;
+ }
+
+ // Build a web path similar to uploadBrandLogo
+ $fsPath = $uploadDir . '/' . $filename;
+
+ $root = rtrim(PROJECT_ROOT, '/\\');
+ $url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
+
+ if ($url === '' || $url[0] !== '/') {
+ $url = '/' . ltrim($url, '/\\');
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'fileName' => $filename,
+ 'url' => $url,
+ ]);
+ exit;
+ }
+
public function siteConfig(): void
{
header('Content-Type: application/json');