release(v2.3.5): make client portals ACL-aware and improve admin UX

This commit is contained in:
Ryan
2025-12-06 04:02:14 -05:00
committed by GitHub
parent acac4235ad
commit a4efa4ff45
8 changed files with 608 additions and 215 deletions

View File

@@ -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

View File

@@ -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'),
]; ];

View File

@@ -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}">

View File

@@ -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 = {};
@@ -279,13 +282,38 @@ export async function openClientPortalsModal() {
(and optionally download) files without seeing your full FileRise UI. (and optionally download) files without seeing your full FileRise UI.
</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>
<span id="clientPortalsStatus" class="small text-muted"></span>
<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> </div>
<span id="clientPortalsStatus" class="small text-muted"></span>
</div>
<div id="clientPortalsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;"> <div id="clientPortalsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
${t('loading')} ${t('loading')}
@@ -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 || '';
@@ -419,8 +497,8 @@ 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"
@@ -439,8 +517,8 @@ 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; // Dont 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';

View File

@@ -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,11 +1010,16 @@ 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 -----------------

View File

@@ -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">

View File

@@ -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);

View File

@@ -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,
]; ];
} }
} }