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 + ? `` + : ''; + const note = hasValue + ? `Saved — leave blank to keep` + : ''; + + return ` +
+ +
+ + ${replaceBtn} +
+ ${note} +
+ `; +} + +/** + * 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 + + +
+ + + 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 + + +
+
+ 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 = ` +
+ + +
+ +
+ + + + 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 = ` -
- - -
- -
- - - Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”. -
- - ${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })} -`; - - wireReplaceButtons(document.getElementById("onlyofficeContent")); - - - - - - // --- Test ONLYOFFICE block --- - const testBox = document.createElement("div"); - testBox.className = "card"; - testBox.style.marginTop = "12px"; - testBox.innerHTML = ` -
-
- Test ONLYOFFICE connection - - -
- - These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding. -
- `; - document.getElementById("onlyofficeContent").appendChild(testBox); - - // Util: tiny UI helpers for results - function ooRow(label, status, detail = "") { - const li = document.createElement("li"); - li.style.margin = "6px 0"; - const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌"; - li.innerHTML = `${icon} ${label}${detail ? ` — ${detail}` : ""}`; - return li; - } - function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); } - - // --- ONLYOFFICE URL sanitizers --- - function getTrustedDocsOrigin(raw) { - try { - const u = new URL(String(raw || "").trim()); - if (!/^https?:$/.test(u.protocol)) return null; // only http/https - if (u.username || u.password) return null; // no creds in URL - return u.origin; // scheme://host[:port] - } catch { - return null; - } - } - - function buildOnlyOfficeApiUrl(origin) { - // fixed path; caller already validated/normalized origin - const u = new URL('/web-apps/apps/api/documents/api.js', origin); - u.searchParams.set('probe', String(Date.now())); - return u.toString(); - } - - - // Probes that don’t explode your state - async function ooProbeScript(docsOrigin) { - return new Promise(resolve => { - const base = getTrustedDocsOrigin(docsOrigin); - if (!base) { resolve({ ok: false }); return; } - - const src = buildOnlyOfficeApiUrl(base); - const s = document.createElement('script'); - s.id = 'ooProbeScript'; - s.async = true; - s.src = src; - - // If you set a CSP nonce in a , attach it: - const nonce = document.querySelector('meta[name="csp-nonce"]')?.content; - if (nonce) s.setAttribute('nonce', nonce); - - const cleanup = () => { try { s.remove(); } catch { } }; - - s.onload = () => { cleanup(); resolve({ ok: true }); }; - s.onerror = () => { cleanup(); resolve({ ok: false }); }; - - // codeql[js/xss-through-dom]: the origin is validated (http/https, no creds), - // and the path is fixed to ONLYOFFICE api.js via URL(), so this is safe. - document.head.appendChild(s); - }); - } - async function ooProbeFrame(docsOrigin, timeoutMs = 4000) { - return new Promise(resolve => { - const base = getTrustedDocsOrigin(docsOrigin); - if (!base) { resolve({ ok: false }); return; } - - const f = document.createElement('iframe'); - f.id = 'ooProbeFrame'; - f.src = base; // only the sanitized origin - f.style.display = 'none'; - - // Optional: keep it extra constrained while probing. - // If your DS needs broader privileges, you can drop sandbox. - // f.sandbox = 'allow-same-origin allow-scripts'; - - const cleanup = () => { try { f.remove(); } catch { } }; - const t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs); - - f.onload = () => { clearTimeout(t); cleanup(); resolve({ ok: true }); }; - f.onerror = () => { clearTimeout(t); cleanup(); resolve({ ok: false }); }; - - // codeql[js/xss-through-dom]: src is constrained to a validated http/https origin. - document.body.appendChild(f); - }); - } - // Main test runner - async function runOnlyOfficeTests() { - const spinner = document.getElementById('ooTestSpinner'); - const out = document.getElementById('ooTestResults'); - const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim(); - - spinner.style.display = 'inline'; - ooClear(out); - - // 1) FileRise status - let statusOk = false, statusJson = null; - try { - const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' }); - statusJson = await r.json().catch(() => ({})); - if (r.ok) { - if (statusJson.enabled) { - out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready')); - statusOk = true; - } else { - // Disabled usually means missing secret or origin; we’ll dig deeper below. - out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin')); - } - } else { - out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`)); - } - } catch (e) { - out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error')); - } - - // 2) Secret presence (fresh read) - try { - const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json()); - const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret); - out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing')); - } catch { - out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify')); - } - - // 3) Callback reachable (basic ping) - try { - const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' }); - if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable')); - else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`)); - } catch { - out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error')); - } - - // Early sanity on origin - if (!/^https?:\/\//i.test(docsOrigin)) { - out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)')); - spinner.style.display = 'none'; - return; - } - - // 4a) Can browser load api.js (also surfaces CSP script-src issues) - const sRes = await ooProbeScript(docsOrigin); - out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)')); - - // 4b) Can browser embed DS in an iframe (CSP frame-src) - const fRes = await ooProbeFrame(docsOrigin); - out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)')); - - // Optional tip if we see common red flags - if (!statusOk || !sRes.ok || !fRes.ok) { - const tip = document.createElement('li'); - tip.style.marginTop = '8px'; - tip.innerHTML = "💡 Tip: Use the CSP helper above to include your Document Server in script-src, connect-src, and frame-src."; - out.appendChild(tip); - } - - spinner.style.display = 'none'; - } - - // Wire the button - document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests); - - - - // Append CSP help box - // --- CSP help box (replace your whole block with this) --- - const ooSec = document.getElementById("onlyofficeContent"); - const cspHelp = document.createElement("div"); - cspHelp.className = "alert alert-info"; - cspHelp.style.marginTop = "12px"; - cspHelp.innerHTML = ` -
- Content-Security-Policy help - - -
-
- Add/replace this line in public/.htaccess (Apache). It allows loading ONLYOFFICE's api.js, - embedding the editor iframe, and letting the script make XHR to your Document Server. -
-

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

-  
-`; - ooSec.appendChild(cspHelp); - - const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM="; - - function buildCspApache(originRaw) { - const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, ''); - const api = `${o}/web-apps/apps/api/documents/api.js`; - return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`; - } - function buildCspNginx(originRaw) { - const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, ''); - const api = `${o}/web-apps/apps/api/documents/api.js`; - return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`; - } - - const ooDocsInput = document.getElementById("ooDocsOrigin"); - const cspPre = document.getElementById("ooCspSnippet"); - const cspPreNgx = document.getElementById("ooCspSnippetNginx"); - - function refreshCsp() { - const raw = (ooDocsInput?.value || "").trim(); - const base = getTrustedDocsOrigin(raw) || raw; // fall back to raw so users see their input - cspPre.textContent = buildCspApache(base); - cspPreNgx.textContent = buildCspNginx(base); - } - ooDocsInput?.addEventListener("input", refreshCsp); - refreshCsp(); - - // ---- Copy helpers (with robust fallback) ---- - async function copyToClipboard(text) { - // Best path: async clipboard API in a secure context (https/localhost) - if (navigator.clipboard && window.isSecureContext) { - try { await navigator.clipboard.writeText(text); return true; } - catch (_) { /* fall through */ } - } - // Fallback for http or blocked clipboard: hidden textarea + execCommand - try { - const ta = document.createElement('textarea'); - ta.value = text; - ta.setAttribute('readonly', ''); - ta.style.position = 'fixed'; - ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.select(); - const ok = document.execCommand('copy'); // deprecated but still widely supported - document.body.removeChild(ta); - return ok; - } catch (_) { - return false; - } - } - function selectElementContents(el) { - const range = document.createRange(); - range.selectNodeContents(el); - const sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - } - - document.getElementById("copyOoCsp")?.addEventListener("click", async () => { - const txt = (cspPre.textContent || "").trim(); - const ok = await copyToClipboard(txt); - if (ok) { - showToast("CSP line copied."); - } else { - // Auto-select so the user can Ctrl/Cmd+C as a last resort - try { selectElementContents(cspPre); } catch { } - const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)"; - showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy."); - } - }); - - document.getElementById("selectOoCsp")?.addEventListener("click", () => { - try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); } - catch { /* ignore */ } - }); - - document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled); - document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : ""; + // 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 = ` -
- -
- - - Open -
-
- -
- -
- - - Open -
-
- - ${(typeof tf === 'function' - ? tf("sponsor_note_fixed", "Please consider supporting ongoing development.") - : "Please consider supporting ongoing development.")} -`; - - // Wire copy + open (no changes tracked) - const ghInput = document.getElementById("sponsorGitHub"); - const kfInput = document.getElementById("sponsorKoFi"); - - document.getElementById("copySponsorGitHub").addEventListener("click", async () => { - try { await navigator.clipboard.writeText(ghInput.value); } catch { } - showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!"); - }); - document.getElementById("copySponsorKoFi").addEventListener("click", async () => { - try { await navigator.clipboard.writeText(kfInput.value); } catch { } - showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!"); - }); - - document.getElementById("openSponsorGitHub").href = SPONSOR_GH; - document.getElementById("openSponsorKoFi").href = SPONSOR_KOFI; + const userMgmt = document.getElementById("userManagementContent"); userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick); @@ -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 = ` - - `; - 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 += ` -
-
- -
- ${label} - ${slug} -
-
- - - -
-
- - -
- - - -
- - - URL: - - ${portalUrl} - - -
- -
- - -
- - -
- - - - -
- -
-
- -
-
- -
- -
- -
-
- -
- -
- -
- -
- Form defaults -
-
- - - -
-
- - - -
-
-
-
- - - -
-
- - - -
-
-
-
-
-
- `; - }); - 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 = ` + + `; + 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 += ` +
+
+ +
+ ${label} + ${slug} +
+
+ + + +
+
+ + +
+ +
+
+ + +
+ + URL: + + ${portalUrl} + + +
+ +
+ + +
+ + +
+ + + + +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ Upload rules +
+ Optional per-portal limits. Leave blank / zero to use global defaults. +
+
+ +
+
+ + +
+
+ + + + Comma-separated, no dots. Empty = allow all. + +
+
+ + + + Simple per-browser guard; 0 = unlimited. + +
+
+ +
+ Thank-you screen +
+ Optionally show a message after a successful upload. +
+ + + + +
+ +
+ +
+ + + File is stored under profile_pics. Leave blank to use the default FileRise logo. + +
+
+ +
+ Intake form +
+ Customize field labels shown on the portal, plus optional defaults & required flags. +
+ +
+ +
+ +
+ + + + +
+ + +
+
+ +
+ + + + +
+ + +
+
+ +
+ + + + +
+ + +
+
+ +
+ + + + +
+ + +
+
+
+
+
+
+ + `; + }); + + 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("open", "Open")} + +
+
+ +
+ +
+ + + + ${tf("open", "Open")} + +
+
+ + + ${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');