release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export
This commit is contained in:
57
CHANGELOG.md
57
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
|
||||
|
||||
@@ -100,6 +100,7 @@ $public = [
|
||||
'introText' => (string)($portal['introText'] ?? ''),
|
||||
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||
'logoFile' => (string)($portal['logoFile'] ?? ''),
|
||||
];
|
||||
|
||||
echo json_encode([
|
||||
|
||||
30
public/api/pro/portals/uploadLogo.php
Normal file
30
public/api/pro/portals/uploadLogo.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/pro/portals/uploadLogo.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Pro-only gate
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => 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(),
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
511
public/js/adminOnlyOffice.js
Normal file
511
public/js/adminOnlyOffice.js
Normal file
@@ -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
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||
: '';
|
||||
const note = hasValue
|
||||
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label for="${id}">${label}:</label>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<input type="${type}" id="${id}" class="form-control" ${disabled} />
|
||||
${replaceBtn}
|
||||
</div>
|
||||
${note}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="card-body">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
||||
<strong>Test ONLYOFFICE connection</strong>
|
||||
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
|
||||
<span id="ooTestSpinner" style="display:none;">⏳</span>
|
||||
</div>
|
||||
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
|
||||
<small class="text-muted">
|
||||
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
|
||||
</small>
|
||||
</div>
|
||||
`;
|
||||
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 =
|
||||
`<span style="min-width:1.2em;display:inline-block">${icon}</span>` +
|
||||
` <strong>${label}</strong>` +
|
||||
(detail ? ` — <span>${detail}</span>` : '');
|
||||
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 =
|
||||
'💡 <em>Tip:</em> Use the CSP helper below to include your Document Server in ' +
|
||||
'<code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.';
|
||||
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 = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<strong>Content-Security-Policy help</strong>
|
||||
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
|
||||
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
|
||||
</div>
|
||||
<div class="form-text" style="margin-bottom:8px;">
|
||||
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
|
||||
embedding the editor iframe, and letting the script make XHR to your Document Server.
|
||||
</div>
|
||||
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
|
||||
<div class="form-text" style="margin-top:8px;">
|
||||
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
|
||||
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
|
||||
otherwise the browser will block it as mixed content.
|
||||
</div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary>Nginx equivalent</summary>
|
||||
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
|
||||
</details>
|
||||
`;
|
||||
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 = `
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="ooEnabled" />
|
||||
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ooDocsOrigin">Document Server Origin:</label>
|
||||
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
|
||||
<small class="text-muted">
|
||||
Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
${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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
})();
|
||||
1574
public/js/adminPortals.js
Normal file
1574
public/js/adminPortals.js
Normal file
File diff suppressed because it is too large
Load Diff
118
public/js/adminSponsor.js
Normal file
118
public/js/adminSponsor.js
Normal file
@@ -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 = `
|
||||
<div class="form-group" style="margin-bottom:12px;">
|
||||
<label for="sponsorGitHub">${tf("github_sponsors_url", "GitHub Sponsors URL")}:</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="url"
|
||||
id="sponsorGitHub"
|
||||
class="form-control"
|
||||
value="${SPONSOR_GH}"
|
||||
readonly
|
||||
data-ignore-dirty="1"
|
||||
/>
|
||||
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">
|
||||
${tf("copy", "Copy")}
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-outline-secondary"
|
||||
id="openSponsorGitHub"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
${tf("open", "Open")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:12px;">
|
||||
<label for="sponsorKoFi">${tf("ko_fi_url", "Ko-fi URL")}:</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="url"
|
||||
id="sponsorKoFi"
|
||||
class="form-control"
|
||||
value="${SPONSOR_KOFI}"
|
||||
readonly
|
||||
data-ignore-dirty="1"
|
||||
/>
|
||||
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">
|
||||
${tf("copy", "Copy")}
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-outline-secondary"
|
||||
id="openSponsorKoFi"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
${tf("open", "Open")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small class="text-muted">
|
||||
${tf("sponsor_note_fixed", "Please consider supporting ongoing development.")}
|
||||
</small>
|
||||
`;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -----------------
|
||||
|
||||
@@ -92,17 +92,19 @@
|
||||
<body data-theme="light">
|
||||
<div class="portal-login-wrapper">
|
||||
<div class="portal-login-card">
|
||||
<div class="portal-login-header">
|
||||
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||
<div>
|
||||
<div id="portalLoginTitle" class="portal-login-title">
|
||||
Sign in to Client Portal
|
||||
</div>
|
||||
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||
to access this client portal
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-login-header">
|
||||
<img id="portalLoginLogo"
|
||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||
alt="FileRise">
|
||||
<div>
|
||||
<div id="portalLoginTitle" class="portal-login-title">
|
||||
Sign in to Client Portal
|
||||
</div>
|
||||
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||
to access this client portal
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="portalLoginError" class="alert alert-danger"></div>
|
||||
|
||||
|
||||
@@ -169,6 +169,9 @@
|
||||
.portal-file-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.portal-required-star {
|
||||
color: #dc3545;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -300,37 +303,38 @@
|
||||
</div>
|
||||
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
|
||||
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
|
||||
|
||||
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||
Please fill in your information before uploading files.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormName">Name</label>
|
||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||
Please fill in your information before uploading files.
|
||||
</p>
|
||||
|
||||
<div id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelName" for="portalFormName">Name</label>
|
||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
|
||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
|
||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
|
||||
<label id="portalFormLabelNotes" for="portalFormNotes">Notes</label>
|
||||
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormEmail">Email</label>
|
||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormReference">Reference / Case / Order #</label>
|
||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:8px;">
|
||||
<label for="portalFormNotes">Notes</label>
|
||||
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="portalUploadSection">
|
||||
<div id="portalDropzone" class="portal-dropzone">
|
||||
@@ -352,6 +356,16 @@
|
||||
</div>
|
||||
<div id="portalFilesList" class="portal-files-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="portalThankYouSection"
|
||||
style="margin-top:12px; display:none;">
|
||||
<div class="alert alert-success" style="font-size:0.9rem; margin-bottom:8px;">
|
||||
<strong>Thank you!</strong>
|
||||
<span id="portalThankYouMessage">
|
||||
Your files have been uploaded.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="portalFooter" class="text-muted"
|
||||
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 488 KiB After Width: | Height: | Size: 562 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 538 KiB |
BIN
resources/dark-client-portal3.png
Normal file
BIN
resources/dark-client-portal3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
BIN
resources/dark-client-portal4.png
Normal file
BIN
resources/dark-client-portal4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 511 KiB |
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user