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;">
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success"> <div>
<i class="material-icons" style="font-size:16px;">cloud_upload</i> <button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
<span style="margin-left:4px;">Add portal</span> <i class="material-icons" style="font-size:16px;">cloud_upload</i>
</button> <span style="margin-left:4px;">Add portal</span>
<span id="clientPortalsStatus" class="small text-muted"></span> </button>
</div>
<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>
</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"
@@ -482,11 +560,11 @@ async function loadClientPortalsList(useCacheOnly) {
/> />
</div> </div>
<label style="display:flex; align-items:center; gap:4px;"> <label style="display:flex; align-items:center; gap:4px;">
<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') || '';
@@ -1453,21 +1600,22 @@ async function saveClientPortalsFromUI() {
const lblRef = getVal('[data-portal-field="lblRef"]').trim(); const lblRef = getVal('[data-portal-field="lblRef"]').trim();
const lblNotes = getVal('[data-portal-field="lblNotes"]').trim(); const lblNotes = getVal('[data-portal-field="lblNotes"]').trim();
const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]'); const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]'); const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]');
const requireFormEl = card.querySelector('[data-portal-field="requireForm"]'); const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
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"]');
const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]'); const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]');
const reqName = reqNameEl ? !!reqNameEl.checked : false; const reqName = reqNameEl ? !!reqNameEl.checked : false;
const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false; const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false;
const reqRef = reqRefEl ? !!reqRefEl.checked : false; const reqRef = reqRefEl ? !!reqRefEl.checked : false;
const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false; const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
const visNameEl = card.querySelector('[data-portal-field="visName"]'); const visNameEl = card.querySelector('[data-portal-field="visName"]');
@@ -1487,63 +1635,106 @@ 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;
} }
portals[slug] = { portals[slug] = {
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,
formDefaults: { formDefaults: {
name: defName, name: defName,
email: defEmail, email: defEmail,
reference: defRef, reference: defRef,
notes: defNotes notes: defNotes
}, },
formRequired: { formRequired: {
name: reqName, name: reqName,
email: reqEmail, email: reqEmail,
reference: reqRef, reference: reqRef,
notes: reqNotes notes: reqNotes
}, },
formLabels: { formLabels: {
name: lblName, name: lblName,
email: lblEmail, email: lblEmail,
reference: lblRef, reference: lblRef,
notes: lblNotes notes: lblNotes
}, },
formVisible: { formVisible: {
name: visName, name: visName,
email: visEmail, email: visEmail,
reference: visRef, reference: visRef,
notes: visNotes notes: visNotes
}, },
uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0, uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
uploadExtWhitelist, uploadExtWhitelist,
uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0, uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
showThankYou, showThankYou,
thankYouText, thankYouText,
}; };
}); });
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;
@@ -900,11 +960,23 @@ async function uploadFiles(fileList) {
// ----------------- Upload UI wiring ----------------- // ----------------- Upload UI wiring -----------------
function wireUploadUI() { function wireUploadUI() {
const drop = qs('portalDropzone'); const drop = qs('portalDropzone');
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,10 +1010,15 @@ function wireUploadUI() {
}); });
} }
// Download / refresh
if (refreshBtn) { if (refreshBtn) {
refreshBtn.addEventListener('click', () => { if (!downloadsEnabled) {
loadPortalFiles(); refreshBtn.style.display = 'none';
}); } else {
refreshBtn.addEventListener('click', () => {
loadPortalFiles();
});
}
} }
} }

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

@@ -317,99 +317,103 @@ public function saveProPortals(array $portalsPayload): void
require_once $proPortalsPath; require_once $proPortalsPath;
if (!is_array($portalsPayload)) { if (!is_array($portalsPayload)) {
throw new InvalidArgumentException('Invalid portals format.'); throw new InvalidArgumentException('Invalid portals format.');
} }
$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)) {
$info = [];
}
$label = trim((string)($info['label'] ?? $slug)); if (!is_array($info)) {
$folder = trim((string)($info['folder'] ?? '')); $info = [];
$clientEmail = trim((string)($info['clientEmail'] ?? '')); }
$uploadOnly = !empty($info['uploadOnly']);
$allowDownload = array_key_exists('allowDownload', $info)
? !empty($info['allowDownload'])
: true;
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
// Branding + form behavior $label = trim((string)($info['label'] ?? $slug));
$title = trim((string)($info['title'] ?? '')); $folder = trim((string)($info['folder'] ?? ''));
$introText = trim((string)($info['introText'] ?? ''));
$requireForm = !empty($info['requireForm']);
$brandColor = trim((string)($info['brandColor'] ?? ''));
$footerText = trim((string)($info['footerText'] ?? ''));
// Optional logo info // Require both slug and folder; collect invalid ones so the UI can warn.
$logoFile = trim((string)($info['logoFile'] ?? '')); if ($slug === '' || $folder === '') {
$logoUrl = trim((string)($info['logoUrl'] ?? '')); $invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
continue;
}
// Upload rules / thank-you behavior $clientEmail = trim((string)($info['clientEmail'] ?? ''));
$uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0; $uploadOnly = !empty($info['uploadOnly']);
$uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? '')); $allowDownload = array_key_exists('allowDownload', $info)
$uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0; ? !empty($info['allowDownload'])
$showThankYou = !empty($info['showThankYou']); : true;
$thankYouText = trim((string)($info['thankYouText'] ?? '')); $expiresAt = trim((string)($info['expiresAt'] ?? ''));
// Form defaults // Branding + form behavior
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults']) $title = trim((string)($info['title'] ?? ''));
? $info['formDefaults'] $introText = trim((string)($info['introText'] ?? ''));
: []; $requireForm = !empty($info['requireForm']);
$brandColor = trim((string)($info['brandColor'] ?? ''));
$footerText = trim((string)($info['footerText'] ?? ''));
$formDefaults = [ // Optional logo info
'name' => trim((string)($formDefaults['name'] ?? '')), $logoFile = trim((string)($info['logoFile'] ?? ''));
'email' => trim((string)($formDefaults['email'] ?? '')), $logoUrl = trim((string)($info['logoUrl'] ?? ''));
'reference' => trim((string)($formDefaults['reference'] ?? '')),
'notes' => trim((string)($formDefaults['notes'] ?? '')),
];
// Required flags // Upload rules / thank-you behavior
$formRequired = isset($info['formRequired']) && is_array($info['formRequired']) $uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
? $info['formRequired'] $uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
: []; $uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
$showThankYou = !empty($info['showThankYou']);
$thankYouText = trim((string)($info['thankYouText'] ?? ''));
$formRequired = [ // Form defaults
'name' => !empty($formRequired['name']), $formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
'email' => !empty($formRequired['email']), ? $info['formDefaults']
'reference' => !empty($formRequired['reference']), : [];
'notes' => !empty($formRequired['notes']),
];
// Labels $formDefaults = [
$formLabels = isset($info['formLabels']) && is_array($info['formLabels']) 'name' => trim((string)($formDefaults['name'] ?? '')),
? $info['formLabels'] 'email' => trim((string)($formDefaults['email'] ?? '')),
: []; 'reference' => trim((string)($formDefaults['reference'] ?? '')),
'notes' => trim((string)($formDefaults['notes'] ?? '')),
];
$formLabels = [ // Required flags
'name' => trim((string)($formLabels['name'] ?? 'Name')), $formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
'email' => trim((string)($formLabels['email'] ?? 'Email')), ? $info['formRequired']
'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')), : [];
'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
];
// Visibility $formRequired = [
$formVisible = isset($info['formVisible']) && is_array($info['formVisible']) 'name' => !empty($formRequired['name']),
? $info['formVisible'] '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']),
];
$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']),
];
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
{ {
@@ -11,29 +12,31 @@ final class PortalController
* *
* Returns: * Returns:
* [ * [
* 'slug' => string, * 'slug' => string,
* '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,
* 'requireForm' => bool, * 'requireForm' => bool,
* 'brandColor' => string, * 'brandColor' => string,
* 'footerText' => string, * 'footerText' => string,
* 'formDefaults' => array, * 'formDefaults' => array,
* 'formRequired' => array, * 'formRequired' => array,
* 'formLabels' => array, * 'formLabels' => array,
* 'formVisible' => array, * 'formVisible' => array,
* 'logoFile' => string, * 'logoFile' => string,
* 'logoUrl' => string, * 'logoUrl' => string,
* 'uploadMaxSizeMb' => int, * 'uploadMaxSizeMb' => int,
* 'uploadExtWhitelist' => string, * 'uploadExtWhitelist' => string,
* '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,21 +69,50 @@ final class PortalController
$p = $portals[$slug]; $p = $portals[$slug];
$label = trim((string)($p['label'] ?? $slug)); // ─────────────────────────────────────────────
$folder = trim((string)($p['folder'] ?? '')); // Normalize upload/download flags (old + new)
$clientEmail = trim((string)($p['clientEmail'] ?? '')); // ─────────────────────────────────────────────
$uploadOnly = !empty($p['uploadOnly']); //
$allowDownload = array_key_exists('allowDownload', $p) // Storage:
? !empty($p['allowDownload']) // - OLD (no allowDownload):
: true; // uploadOnly=true => upload yes, download no
$expiresAt = trim((string)($p['expiresAt'] ?? '')); // 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));
$folder = trim((string)($p['folder'] ?? ''));
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
// Branding + intake behavior // Branding + intake behavior
$title = trim((string)($p['title'] ?? '')); $title = trim((string)($p['title'] ?? ''));
$introText = trim((string)($p['introText'] ?? '')); $introText = trim((string)($p['introText'] ?? ''));
$requireForm = !empty($p['requireForm']); $requireForm = !empty($p['requireForm']);
$brandColor = trim((string)($p['brandColor'] ?? '')); $brandColor = trim((string)($p['brandColor'] ?? ''));
$footerText = trim((string)($p['footerText'] ?? '')); $footerText = trim((string)($p['footerText'] ?? ''));
// Defaults / required // Defaults / required
$fd = isset($p['formDefaults']) && is_array($p['formDefaults']) $fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
@@ -134,11 +166,11 @@ final class PortalController
$logoUrl = trim((string)($p['logoUrl'] ?? '')); $logoUrl = trim((string)($p['logoUrl'] ?? ''));
// Upload rules / thank-you behavior // Upload rules / thank-you behavior
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0; $uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? '')); $uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0; $uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
$showThankYou = !empty($p['showThankYou']); $showThankYou = !empty($p['showThankYou']);
$thankYouText = trim((string)($p['thankYouText'] ?? '')); $thankYouText = trim((string)($p['thankYouText'] ?? ''));
if ($folder === '') { if ($folder === '') {
throw new RuntimeException('Portal misconfigured: empty folder.'); throw new RuntimeException('Portal misconfigured: empty folder.');
@@ -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,
]; ];
} }
} }