release(v2.3.5): make client portals ACL-aware and improve admin UX
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,9 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changese 12/6/2025 (v2.3.5)
|
||||||
|
|
||||||
|
release(v2.3.5): make client portals ACL-aware and improve admin UX
|
||||||
|
|
||||||
|
- Wire PortalController into ACL.php and expose canUpload/canDownload flags
|
||||||
|
- Gate portal uploads/downloads on both portal flags and folder ACL for logged-in users
|
||||||
|
- Normalize legacy portal JSON (uploadOnly) with new allowDownload checkbox semantics
|
||||||
|
- Disable portal upload UI when uploads are turned off; hide refresh when downloads are disabled
|
||||||
|
- Improve portal subtitles (“Upload & download”, “Upload only”, etc.) and status messaging
|
||||||
|
- Add quick-access buttons in Client Portals modal for Add user, Folder access, and User groups
|
||||||
|
- Enforce slug + folder as required on both frontend and backend, with inline hints and scroll-to-first-error
|
||||||
|
- Auto-focus newly created portals’ folder input for faster setup
|
||||||
|
- Raise user permissions modal z-index so it appears above the portals modal
|
||||||
|
- Enhance portal form submission logging with better client IP detection (X-Forwarded-For / X-Real-IP aware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 12/5/2025 (v2.3.4)
|
## Changes 12/5/2025 (v2.3.4)
|
||||||
|
|
||||||
release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL
|
release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 12/5/2025 (v2.3.3)
|
## Changes 12/5/2025 (v2.3.3)
|
||||||
|
|
||||||
release(v2.3.3): footer branding, Pro bundle UX + file list polish
|
release(v2.3.3): footer branding, Pro bundle UX + file list polish
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ try {
|
|||||||
require_once $subPath;
|
require_once $subPath;
|
||||||
|
|
||||||
$submittedBy = (string)($_SESSION['username'] ?? '');
|
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||||
|
|
||||||
|
// ─────────────────────────────
|
||||||
|
// Better client IP detection
|
||||||
|
// ─────────────────────────────
|
||||||
|
$ip = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
// Can be a comma-separated list; use the first non-empty
|
||||||
|
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$candidate = trim($part);
|
||||||
|
if ($candidate !== '') {
|
||||||
|
$ip = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||||
|
$ip = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||||
|
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'portalLabel' => $portal['label'] ?? '',
|
'portalLabel' => $portal['label'] ?? '',
|
||||||
@@ -69,7 +90,7 @@ try {
|
|||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
],
|
],
|
||||||
'submittedBy' => $submittedBy,
|
'submittedBy' => $submittedBy,
|
||||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
'ip' => $ip,
|
||||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
'createdAt' => gmdate('c'),
|
'createdAt' => gmdate('c'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1968,7 +1968,7 @@ export function openUserPermissionsModal() {
|
|||||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||||
background-color: ${overlayBackground};
|
background-color: ${overlayBackground};
|
||||||
display: flex; justify-content: center; align-items: center;
|
display: flex; justify-content: center; align-items: center;
|
||||||
z-index: 3500;
|
z-index: 10000;
|
||||||
`;
|
`;
|
||||||
userPermissionsModal.innerHTML = `
|
userPermissionsModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ let __portalsCache = {};
|
|||||||
let __portalFolderListLoaded = false;
|
let __portalFolderListLoaded = false;
|
||||||
let __portalFolderOptions = [];
|
let __portalFolderOptions = [];
|
||||||
|
|
||||||
|
// Remember a newly-created portal to focus its folder field
|
||||||
|
let __portalSlugToFocus = null;
|
||||||
|
|
||||||
// Cache portal submissions per slug for CSV export
|
// Cache portal submissions per slug for CSV export
|
||||||
const __portalSubmissionsCache = {};
|
const __portalSubmissionsCache = {};
|
||||||
|
|
||||||
@@ -280,10 +283,35 @@ export async function openClientPortalsModal() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
|
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
|
||||||
|
<div>
|
||||||
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
|
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
|
||||||
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
||||||
<span style="margin-left:4px;">Add portal</span>
|
<span style="margin-left:4px;">Add portal</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
id="clientPortalsQuickAddUser"
|
||||||
|
class="btn btn-sm btn-outline-primary ms-1">
|
||||||
|
<i class="material-icons" style="font-size:16px; vertical-align:middle;">person_add</i>
|
||||||
|
<span style="margin-left:4px;">Add user…</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="clientPortalsOpenUserPerms"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-1">
|
||||||
|
<i class="material-icons" style="font-size:16px; vertical-align:middle;">folder_shared</i>
|
||||||
|
<span style="margin-left:4px;">Folder access…</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="clientPortalsOpenUserGroups"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-1">
|
||||||
|
<i class="material-icons" style="font-size:16px; vertical-align:middle;">groups</i>
|
||||||
|
<span style="margin-left:4px;">User groups…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span id="clientPortalsStatus" class="small text-muted"></span>
|
<span id="clientPortalsStatus" class="small text-muted"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,6 +331,41 @@ export async function openClientPortalsModal() {
|
|||||||
document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
|
document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
|
||||||
document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
|
document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
|
||||||
document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
|
document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
|
||||||
|
const quickAddUserBtn = document.getElementById('clientPortalsQuickAddUser');
|
||||||
|
if (quickAddUserBtn) {
|
||||||
|
quickAddUserBtn.onclick = () => {
|
||||||
|
// Reuse existing admin add-user button / modal
|
||||||
|
const globalBtn = document.getElementById('adminOpenAddUser');
|
||||||
|
if (globalBtn) {
|
||||||
|
globalBtn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Use the Users tab to add a new user.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const openPermsBtn = document.getElementById('clientPortalsOpenUserPerms');
|
||||||
|
if (openPermsBtn) {
|
||||||
|
openPermsBtn.onclick = () => {
|
||||||
|
const btn = document.getElementById('adminOpenUserPermissions');
|
||||||
|
if (btn) {
|
||||||
|
btn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Use the Users tab to edit folder access.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const openGroupsBtn = document.getElementById('clientPortalsOpenUserGroups');
|
||||||
|
if (openGroupsBtn) {
|
||||||
|
openGroupsBtn.onclick = () => {
|
||||||
|
const btn = document.getElementById('adminOpenUserGroups');
|
||||||
|
if (btn) {
|
||||||
|
btn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Use the Users tab to manage user groups.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
modal.style.background = overlayBg;
|
modal.style.background = overlayBg;
|
||||||
const content = modal.querySelector('.modal-content');
|
const content = modal.querySelector('.modal-content');
|
||||||
@@ -358,7 +421,22 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
const folder = p.folder || '';
|
const folder = p.folder || '';
|
||||||
const clientEmail = p.clientEmail || '';
|
const clientEmail = p.clientEmail || '';
|
||||||
const uploadOnly = !!p.uploadOnly;
|
const uploadOnly = !!p.uploadOnly;
|
||||||
const allowDownload = p.allowDownload !== false; // default true
|
|
||||||
|
// Backwards compat:
|
||||||
|
// - Old portals only had "uploadOnly":
|
||||||
|
// uploadOnly = true => upload yes, download no
|
||||||
|
// uploadOnly = false => upload yes, download yes
|
||||||
|
// - New portals have explicit allowDownload.
|
||||||
|
let allowDownload;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(p, 'allowDownload')) {
|
||||||
|
allowDownload = p.allowDownload !== false;
|
||||||
|
} else {
|
||||||
|
// Legacy: "upload only" meant no download
|
||||||
|
allowDownload = !uploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
|
const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
|
||||||
const brandColor = p.brandColor || '';
|
const brandColor = p.brandColor || '';
|
||||||
const footerText = p.footerText || '';
|
const footerText = p.footerText || '';
|
||||||
@@ -420,7 +498,7 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
<div class="portal-card-body">
|
<div class="portal-card-body">
|
||||||
<div class="portal-meta-row">
|
<div class="portal-meta-row">
|
||||||
<label style="font-weight:600;">
|
<label style="font-weight:600;">
|
||||||
Portal slug:
|
Portal slug<span class="text-danger">*</span>:
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-sm"
|
class="form-control form-control-sm"
|
||||||
data-portal-field="slug"
|
data-portal-field="slug"
|
||||||
@@ -440,7 +518,7 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
<div class="portal-meta-row">
|
<div class="portal-meta-row">
|
||||||
<div class="portal-folder-row">
|
<div class="portal-folder-row">
|
||||||
<label>
|
<label>
|
||||||
Folder:
|
Folder<span class="text-danger">*</span>:
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-sm portal-folder-input"
|
class="form-control form-control-sm portal-folder-input"
|
||||||
data-portal-field="folder"
|
data-portal-field="folder"
|
||||||
@@ -486,7 +564,7 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
data-portal-field="uploadOnly"
|
data-portal-field="uploadOnly"
|
||||||
${uploadOnly ? 'checked' : ''} />
|
${uploadOnly ? 'checked' : ''} />
|
||||||
<span>Upload only</span>
|
<span>Allow upload</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style="display:flex; align-items:center; gap:4px;">
|
<label style="display:flex; align-items:center; gap:4px;">
|
||||||
@@ -495,7 +573,6 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
${allowDownload ? 'checked' : ''} />
|
${allowDownload ? 'checked' : ''} />
|
||||||
<span>Allow download</span>
|
<span>Allow download</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:8px;">
|
<div style="margin-top:8px;">
|
||||||
<div class="form-group" style="margin-bottom:6px;">
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
@@ -840,6 +917,32 @@ body.querySelectorAll('[data-portal-action="delete"]').forEach(btn => {
|
|||||||
card.remove();
|
card.remove();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// After rendering, if we have a "new" portal to focus, expand it and focus Folder
|
||||||
|
if (__portalSlugToFocus) {
|
||||||
|
const focusSlug = __portalSlugToFocus;
|
||||||
|
__portalSlugToFocus = null;
|
||||||
|
|
||||||
|
const focusCard = body.querySelector(`.portal-card[data-portal-slug="${focusSlug}"]`);
|
||||||
|
if (focusCard) {
|
||||||
|
const header = focusCard.querySelector('.portal-card-header');
|
||||||
|
const bodyEl = focusCard.querySelector('.portal-card-body');
|
||||||
|
const caret = focusCard.querySelector('.portal-card-caret');
|
||||||
|
|
||||||
|
if (header && bodyEl) {
|
||||||
|
header.setAttribute('aria-expanded', 'true');
|
||||||
|
bodyEl.style.display = 'block';
|
||||||
|
if (caret) caret.textContent = '▾';
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderInput = focusCard.querySelector('[data-portal-field="folder"]');
|
||||||
|
if (folderInput) {
|
||||||
|
folderInput.focus();
|
||||||
|
folderInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
focusCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Keep submissions viewer working
|
// Keep submissions viewer working
|
||||||
attachPortalSubmissionsUI();
|
attachPortalSubmissionsUI();
|
||||||
// Intake presets dropdowns
|
// Intake presets dropdowns
|
||||||
@@ -881,6 +984,8 @@ function addEmptyPortalRow() {
|
|||||||
expiresAt: ''
|
expiresAt: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// After re-render, auto-focus this portal's folder field
|
||||||
|
__portalSlugToFocus = slug;
|
||||||
loadClientPortalsList(true);
|
loadClientPortalsList(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1421,6 +1526,48 @@ async function saveClientPortalsFromUI() {
|
|||||||
|
|
||||||
const cards = body.querySelectorAll('.card[data-portal-slug]');
|
const cards = body.querySelectorAll('.card[data-portal-slug]');
|
||||||
const portals = {};
|
const portals = {};
|
||||||
|
const invalid = [];
|
||||||
|
let firstInvalidField = null;
|
||||||
|
|
||||||
|
// Clear previous visual errors
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.style.boxShadow = '';
|
||||||
|
card.style.borderColor = '';
|
||||||
|
card.classList.remove('portal-card-has-error');
|
||||||
|
|
||||||
|
const hint = card.querySelector('.portal-card-error-hint');
|
||||||
|
if (hint) hint.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const markCardMissingRequired = (card, message) => {
|
||||||
|
// Mark visually
|
||||||
|
card.classList.add('portal-card-has-error');
|
||||||
|
card.style.borderColor = '#dc3545';
|
||||||
|
card.style.boxShadow = '0 0 0 2px rgba(220,53,69,0.6)';
|
||||||
|
|
||||||
|
// Expand the card so the error is visible even if it was collapsed
|
||||||
|
const header = card.querySelector('.portal-card-header');
|
||||||
|
const bodyEl = card.querySelector('.portal-card-body') || card;
|
||||||
|
const caret = card.querySelector('.portal-card-caret');
|
||||||
|
|
||||||
|
if (header && bodyEl) {
|
||||||
|
header.setAttribute('aria-expanded', 'true');
|
||||||
|
bodyEl.style.display = 'block';
|
||||||
|
if (caret) caret.textContent = '▾';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small inline hint at top of the card body
|
||||||
|
let hint = bodyEl.querySelector('.portal-card-error-hint');
|
||||||
|
if (!hint) {
|
||||||
|
hint = document.createElement('div');
|
||||||
|
hint.className = 'portal-card-error-hint text-danger small';
|
||||||
|
hint.style.marginBottom = '6px';
|
||||||
|
hint.textContent = message || 'Slug and folder are required. This portal will not be saved until both are filled.';
|
||||||
|
bodyEl.insertBefore(hint, bodyEl.firstChild);
|
||||||
|
} else {
|
||||||
|
hint.textContent = message || hint.textContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
const origSlug = card.getAttribute('data-portal-slug') || '';
|
const origSlug = card.getAttribute('data-portal-slug') || '';
|
||||||
@@ -1460,6 +1607,7 @@ async function saveClientPortalsFromUI() {
|
|||||||
const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
|
const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
|
||||||
const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
|
const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
|
||||||
const requireForm = requireFormEl ? !!requireFormEl.checked : false;
|
const requireForm = requireFormEl ? !!requireFormEl.checked : false;
|
||||||
|
|
||||||
const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
|
const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
|
||||||
const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
|
const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
|
||||||
const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
|
const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
|
||||||
@@ -1487,14 +1635,34 @@ async function saveClientPortalsFromUI() {
|
|||||||
|
|
||||||
const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
|
const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
|
||||||
const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
|
const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
|
||||||
|
const folderInput = card.querySelector('[data-portal-field="folder"]');
|
||||||
const slugInput = card.querySelector('[data-portal-field="slug"]');
|
const slugInput = card.querySelector('[data-portal-field="slug"]');
|
||||||
if (slugInput) {
|
if (slugInput) {
|
||||||
const rawSlug = slugInput.value.trim();
|
const rawSlug = slugInput.value.trim();
|
||||||
if (rawSlug) slug = rawSlug;
|
if (rawSlug) slug = rawSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelForError = label || slug || origSlug || '(unnamed portal)';
|
||||||
|
|
||||||
|
// Validation: slug + folder required
|
||||||
if (!slug || !folder) {
|
if (!slug || !folder) {
|
||||||
|
invalid.push(labelForError);
|
||||||
|
|
||||||
|
// Remember the first problematic field so we can scroll exactly to it
|
||||||
|
if (!firstInvalidField) {
|
||||||
|
if (!folder && folderInput) {
|
||||||
|
firstInvalidField = folderInput;
|
||||||
|
} else if (!slug && slugInput) {
|
||||||
|
firstInvalidField = slugInput;
|
||||||
|
} else {
|
||||||
|
firstInvalidField = card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markCardMissingRequired(
|
||||||
|
card,
|
||||||
|
'Slug and folder are required. This portal will not be saved until both are filled.'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1544,6 +1712,29 @@ async function saveClientPortalsFromUI() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (invalid.length) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Please fill slug and folder for highlighted portals.';
|
||||||
|
status.className = 'small text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the *first missing field* into view so the admin sees exactly where to fix
|
||||||
|
const targetEl = firstInvalidField || body.querySelector('.portal-card-has-error');
|
||||||
|
if (targetEl) {
|
||||||
|
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// If it's an input, focus + select to make typing instant
|
||||||
|
if (typeof targetEl.focus === 'function') {
|
||||||
|
targetEl.focus();
|
||||||
|
if (typeof targetEl.select === 'function') {
|
||||||
|
targetEl.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Please set slug and folder for: ' + invalid.join(', '));
|
||||||
|
return; // Don’t hit the API if local validation failed
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = 'Saving…';
|
status.textContent = 'Saving…';
|
||||||
status.className = 'small text-muted';
|
status.className = 'small text-muted';
|
||||||
|
|||||||
@@ -10,10 +10,33 @@ function portalFolder() {
|
|||||||
return portal.folder || portal.targetFolder || portal.path || 'root';
|
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function portalCanUpload() {
|
||||||
|
if (!portal) return false;
|
||||||
|
|
||||||
|
// Prefer explicit flags from backend (PortalController)
|
||||||
|
if (typeof portal.canUpload !== 'undefined') {
|
||||||
|
return !!portal.canUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks for older bundles (if you ever add these)
|
||||||
|
if (typeof portal.allowUpload !== 'undefined') {
|
||||||
|
return !!portal.allowUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy behavior: portals were always upload-capable;
|
||||||
|
// uploadOnly only controlled download visibility.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function portalCanDownload() {
|
function portalCanDownload() {
|
||||||
if (!portal) return false;
|
if (!portal) return false;
|
||||||
|
|
||||||
// Prefer explicit flags if present
|
// Prefer explicit flag if present (PortalController)
|
||||||
|
if (typeof portal.canDownload !== 'undefined') {
|
||||||
|
return !!portal.canDownload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to allowDownload / allowDownloads (older payloads)
|
||||||
if (typeof portal.allowDownload !== 'undefined') {
|
if (typeof portal.allowDownload !== 'undefined') {
|
||||||
return !!portal.allowDownload;
|
return !!portal.allowDownload;
|
||||||
}
|
}
|
||||||
@@ -21,7 +44,7 @@ function portalCanDownload() {
|
|||||||
return !!portal.allowDownloads;
|
return !!portal.allowDownloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: uploadOnly = true => no downloads
|
// Legacy: uploadOnly = true => no downloads
|
||||||
if (typeof portal.uploadOnly !== 'undefined') {
|
if (typeof portal.uploadOnly !== 'undefined') {
|
||||||
return !portal.uploadOnly;
|
return !portal.uploadOnly;
|
||||||
}
|
}
|
||||||
@@ -260,7 +283,7 @@ function setupPortalForm(slug) {
|
|||||||
const formSection = qs('portalFormSection');
|
const formSection = qs('portalFormSection');
|
||||||
const uploadSection = qs('portalUploadSection');
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
if (!portal || !portal.requireForm) {
|
if (!portal || !portal.requireForm || !portalCanUpload()) {
|
||||||
if (formSection) formSection.style.display = 'none';
|
if (formSection) formSection.style.display = 'none';
|
||||||
if (uploadSection) uploadSection.style.opacity = '1';
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
return;
|
return;
|
||||||
@@ -549,11 +572,21 @@ function renderPortalInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadsEnabled = portalCanUpload();
|
||||||
|
const downloadsEnabled = portalCanDownload();
|
||||||
|
|
||||||
if (subtitleEl) {
|
if (subtitleEl) {
|
||||||
const parts = [];
|
let text = '';
|
||||||
if (portal.uploadOnly) parts.push('upload only');
|
if (uploadsEnabled && downloadsEnabled) {
|
||||||
if (portalCanDownload()) parts.push('download allowed');
|
text = 'Upload & download';
|
||||||
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
} else if (uploadsEnabled && !downloadsEnabled) {
|
||||||
|
text = 'Upload only';
|
||||||
|
} else if (!uploadsEnabled && downloadsEnabled) {
|
||||||
|
text = 'Download only';
|
||||||
|
} else {
|
||||||
|
text = 'Access only';
|
||||||
|
}
|
||||||
|
subtitleEl.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footerEl) {
|
if (footerEl) {
|
||||||
@@ -561,6 +594,26 @@ function renderPortalInfo() {
|
|||||||
? portal.footerText.trim()
|
? portal.footerText.trim()
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formSection = qs('portalFormSection');
|
||||||
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
|
// If uploads are disabled, hide upload + form (form is only meaningful for uploads)
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
if (formSection) {
|
||||||
|
formSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (uploadSection) {
|
||||||
|
uploadSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusEl = qs('portalStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = 'Uploads are disabled for this portal.';
|
||||||
|
statusEl.classList.remove('text-muted');
|
||||||
|
statusEl.classList.add('text-warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
applyPortalFormLabels();
|
applyPortalFormLabels();
|
||||||
const color = portal.brandColor && portal.brandColor.trim();
|
const color = portal.brandColor && portal.brandColor.trim();
|
||||||
if (color) {
|
if (color) {
|
||||||
@@ -741,6 +794,13 @@ async function loadPortalFiles() {
|
|||||||
// ----------------- Upload -----------------
|
// ----------------- Upload -----------------
|
||||||
async function uploadFiles(fileList) {
|
async function uploadFiles(fileList) {
|
||||||
if (!portal || !fileList || !fileList.length) return;
|
if (!portal || !fileList || !fileList.length) return;
|
||||||
|
|
||||||
|
if (!portalCanUpload()) {
|
||||||
|
showToast('Uploads are disabled for this portal.');
|
||||||
|
setStatus('Uploads are disabled for this portal.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (portal.requireForm && !portalFormDone) {
|
if (portal.requireForm && !portalFormDone) {
|
||||||
showToast('Please fill in your details before uploading.');
|
showToast('Please fill in your details before uploading.');
|
||||||
return;
|
return;
|
||||||
@@ -904,7 +964,19 @@ function wireUploadUI() {
|
|||||||
const input = qs('portalFileInput');
|
const input = qs('portalFileInput');
|
||||||
const refreshBtn = qs('portalRefreshBtn');
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
|
||||||
if (drop && input) {
|
const uploadsEnabled = portalCanUpload();
|
||||||
|
const downloadsEnabled = portalCanDownload();
|
||||||
|
|
||||||
|
// Upload UI
|
||||||
|
if (drop) {
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
// Visually dim + disable clicks
|
||||||
|
drop.classList.add('portal-dropzone-disabled');
|
||||||
|
drop.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadsEnabled && drop && input) {
|
||||||
drop.addEventListener('click', () => input.click());
|
drop.addEventListener('click', () => input.click());
|
||||||
|
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
@@ -938,12 +1010,17 @@ function wireUploadUI() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download / refresh
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
|
if (!downloadsEnabled) {
|
||||||
|
refreshBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
refreshBtn.addEventListener('click', () => {
|
refreshBtn.addEventListener('click', () => {
|
||||||
loadPortalFiles();
|
loadPortalFiles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------- Slug + init -----------------
|
// ----------------- Slug + init -----------------
|
||||||
function getPortalSlugFromUrl() {
|
function getPortalSlugFromUrl() {
|
||||||
|
|||||||
@@ -172,6 +172,11 @@
|
|||||||
.portal-required-star {
|
.portal-required-star {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
.portal-dropzone.portal-dropzone-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-style: solid;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|||||||
@@ -322,18 +322,24 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = ['portals' => []];
|
$data = ['portals' => []];
|
||||||
|
$invalid = [];
|
||||||
|
|
||||||
foreach ($portalsPayload as $slug => $info) {
|
foreach ($portalsPayload as $slug => $info) {
|
||||||
$slug = trim((string)$slug);
|
$slug = trim((string)$slug);
|
||||||
if ($slug === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!is_array($info)) {
|
if (!is_array($info)) {
|
||||||
$info = [];
|
$info = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = trim((string)($info['label'] ?? $slug));
|
$label = trim((string)($info['label'] ?? $slug));
|
||||||
$folder = trim((string)($info['folder'] ?? ''));
|
$folder = trim((string)($info['folder'] ?? ''));
|
||||||
|
|
||||||
|
// Require both slug and folder; collect invalid ones so the UI can warn.
|
||||||
|
if ($slug === '' || $folder === '') {
|
||||||
|
$invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||||
$uploadOnly = !empty($info['uploadOnly']);
|
$uploadOnly = !empty($info['uploadOnly']);
|
||||||
$allowDownload = array_key_exists('allowDownload', $info)
|
$allowDownload = array_key_exists('allowDownload', $info)
|
||||||
@@ -407,9 +413,7 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
|
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($folder === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['portals'][$slug] = [
|
$data['portals'][$slug] = [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
@@ -436,6 +440,12 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
'formVisible' => $formVisible,
|
'formVisible' => $formVisible,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (!empty($invalid)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'One or more portals are missing a slug or folder: ' . implode(', ', $invalid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||||
$ok = $store->savePortals($data);
|
$ok = $store->savePortals($data);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
final class PortalController
|
final class PortalController
|
||||||
{
|
{
|
||||||
@@ -15,8 +16,8 @@ final class PortalController
|
|||||||
* 'label' => string,
|
* 'label' => string,
|
||||||
* 'folder' => string,
|
* 'folder' => string,
|
||||||
* 'clientEmail' => string,
|
* 'clientEmail' => string,
|
||||||
* 'uploadOnly' => bool,
|
* 'uploadOnly' => bool, // stored flag (legacy name)
|
||||||
* 'allowDownload' => bool,
|
* 'allowDownload' => bool, // stored flag
|
||||||
* 'expiresAt' => string,
|
* 'expiresAt' => string,
|
||||||
* 'title' => string,
|
* 'title' => string,
|
||||||
* 'introText' => string,
|
* 'introText' => string,
|
||||||
@@ -34,6 +35,8 @@ final class PortalController
|
|||||||
* 'uploadMaxPerDay' => int,
|
* 'uploadMaxPerDay' => int,
|
||||||
* 'showThankYou' => bool,
|
* 'showThankYou' => bool,
|
||||||
* 'thankYouText' => string,
|
* 'thankYouText' => string,
|
||||||
|
* 'canUpload' => bool, // ACL + portal flags
|
||||||
|
* 'canDownload' => bool, // ACL + portal flags
|
||||||
* ]
|
* ]
|
||||||
*/
|
*/
|
||||||
public static function getPortalBySlug(string $slug): array
|
public static function getPortalBySlug(string $slug): array
|
||||||
@@ -66,13 +69,42 @@ final class PortalController
|
|||||||
|
|
||||||
$p = $portals[$slug];
|
$p = $portals[$slug];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Normalize upload/download flags (old + new)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Storage:
|
||||||
|
// - OLD (no allowDownload):
|
||||||
|
// uploadOnly=true => upload yes, download no
|
||||||
|
// uploadOnly=false => upload yes, download yes
|
||||||
|
//
|
||||||
|
// - NEW:
|
||||||
|
// "Allow upload" checkbox is stored as uploadOnly (🤮 name, but we keep it)
|
||||||
|
// "Allow download" checkbox is stored as allowDownload
|
||||||
|
//
|
||||||
|
// Normalized flags we want here:
|
||||||
|
// - $allowUpload (bool)
|
||||||
|
// - $allowDownload (bool)
|
||||||
|
$hasAllowDownload = array_key_exists('allowDownload', $p);
|
||||||
|
$rawUploadOnly = !empty($p['uploadOnly']); // legacy name
|
||||||
|
$rawAllowDownload = $hasAllowDownload ? !empty($p['allowDownload']) : null;
|
||||||
|
|
||||||
|
if ($hasAllowDownload) {
|
||||||
|
// New JSON – trust both checkboxes exactly
|
||||||
|
$allowUpload = $rawUploadOnly; // "Allow upload" in UI
|
||||||
|
$allowDownload = (bool)$rawAllowDownload;
|
||||||
|
} else {
|
||||||
|
// Legacy JSON – no separate allowDownload
|
||||||
|
// uploadOnly=true => upload yes, download no
|
||||||
|
// uploadOnly=false => upload yes, download yes
|
||||||
|
$allowUpload = true;
|
||||||
|
$allowDownload = !$rawUploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
$label = trim((string)($p['label'] ?? $slug));
|
$label = trim((string)($p['label'] ?? $slug));
|
||||||
$folder = trim((string)($p['folder'] ?? ''));
|
$folder = trim((string)($p['folder'] ?? ''));
|
||||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||||
$uploadOnly = !empty($p['uploadOnly']);
|
|
||||||
$allowDownload = array_key_exists('allowDownload', $p)
|
|
||||||
? !empty($p['allowDownload'])
|
|
||||||
: true;
|
|
||||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||||
|
|
||||||
// Branding + intake behavior
|
// Branding + intake behavior
|
||||||
@@ -152,13 +184,48 @@ final class PortalController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────
|
||||||
|
// Capability flags (portal + ACL)
|
||||||
|
// ──────────────────────────────
|
||||||
|
//
|
||||||
|
// Base from portal config:
|
||||||
|
$canUpload = (bool)$allowUpload;
|
||||||
|
$canDownload = (bool)$allowDownload;
|
||||||
|
|
||||||
|
// Refine with ACL for the current logged-in user (if any)
|
||||||
|
$user = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($user !== '') {
|
||||||
|
// Upload: must also pass folder-level ACL
|
||||||
|
if ($canUpload && !ACL::canUpload($user, $perms, $folder)) {
|
||||||
|
$canUpload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download: require read or read_own
|
||||||
|
if (
|
||||||
|
$canDownload
|
||||||
|
&& !ACL::canRead($user, $perms, $folder)
|
||||||
|
&& !ACL::canReadOwn($user, $perms, $folder)
|
||||||
|
) {
|
||||||
|
$canDownload = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'folder' => $folder,
|
'folder' => $folder,
|
||||||
'clientEmail' => $clientEmail,
|
'clientEmail' => $clientEmail,
|
||||||
'uploadOnly' => $uploadOnly,
|
// Store flags as-is so old code / JSON stay compatible
|
||||||
'allowDownload' => $allowDownload,
|
'uploadOnly' => (bool)$rawUploadOnly,
|
||||||
|
'allowDownload' => $hasAllowDownload
|
||||||
|
? (bool)$rawAllowDownload
|
||||||
|
: $allowDownload,
|
||||||
'expiresAt' => $expiresAt,
|
'expiresAt' => $expiresAt,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'introText' => $introText,
|
'introText' => $introText,
|
||||||
@@ -176,6 +243,9 @@ final class PortalController
|
|||||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||||
'showThankYou' => $showThankYou,
|
'showThankYou' => $showThankYou,
|
||||||
'thankYouText' => $thankYouText,
|
'thankYouText' => $thankYouText,
|
||||||
|
// New ACL-aware caps for portal.js
|
||||||
|
'canUpload' => $canUpload,
|
||||||
|
'canDownload' => $canDownload,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user