@@ -840,6 +917,32 @@ body.querySelectorAll('[data-portal-action="delete"]').forEach(btn => {
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
attachPortalSubmissionsUI();
// Intake presets dropdowns
@@ -881,6 +984,8 @@ function addEmptyPortalRow() {
expiresAt: ''
};
+ // After re-render, auto-focus this portal's folder field
+ __portalSlugToFocus = slug;
loadClientPortalsList(true);
}
@@ -1421,6 +1526,48 @@ async function saveClientPortalsFromUI() {
const cards = body.querySelectorAll('.card[data-portal-slug]');
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 => {
const origSlug = card.getAttribute('data-portal-slug') || '';
@@ -1453,21 +1600,22 @@ async function saveClientPortalsFromUI() {
const lblRef = getVal('[data-portal-field="lblRef"]').trim();
const lblNotes = getVal('[data-portal-field="lblNotes"]').trim();
- const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
+ const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
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 requireForm = requireFormEl ? !!requireFormEl.checked : false;
- const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
+ const requireForm = requireFormEl ? !!requireFormEl.checked : false;
+
+ const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
- const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
+ const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
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 reqRef = reqRefEl ? !!reqRefEl.checked : false;
+ const reqRef = reqRefEl ? !!reqRefEl.checked : false;
const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
const visNameEl = card.querySelector('[data-portal-field="visName"]');
@@ -1487,63 +1635,106 @@ async function saveClientPortalsFromUI() {
const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
-
+ const folderInput = card.querySelector('[data-portal-field="folder"]');
const slugInput = card.querySelector('[data-portal-field="slug"]');
if (slugInput) {
const rawSlug = slugInput.value.trim();
if (rawSlug) slug = rawSlug;
}
+ const labelForError = label || slug || origSlug || '(unnamed portal)';
+
+ // Validation: slug + folder required
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;
}
portals[slug] = {
- label,
- folder,
- clientEmail,
- uploadOnly,
- allowDownload,
- expiresAt,
- title,
- introText,
- requireForm,
- brandColor,
- footerText,
- logoFile,
- logoUrl,
- formDefaults: {
- name: defName,
- email: defEmail,
- reference: defRef,
- notes: defNotes
- },
- formRequired: {
- name: reqName,
- email: reqEmail,
- reference: reqRef,
- notes: reqNotes
- },
- formLabels: {
- name: lblName,
- email: lblEmail,
- reference: lblRef,
- notes: lblNotes
- },
- formVisible: {
- name: visName,
- email: visEmail,
- reference: visRef,
- notes: visNotes
- },
- uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
- uploadExtWhitelist,
- uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
- showThankYou,
- thankYouText,
- };
+ label,
+ folder,
+ clientEmail,
+ uploadOnly,
+ allowDownload,
+ expiresAt,
+ title,
+ introText,
+ requireForm,
+ brandColor,
+ footerText,
+ logoFile,
+ logoUrl,
+ formDefaults: {
+ name: defName,
+ email: defEmail,
+ reference: defRef,
+ notes: defNotes
+ },
+ formRequired: {
+ name: reqName,
+ email: reqEmail,
+ reference: reqRef,
+ notes: reqNotes
+ },
+ formLabels: {
+ name: lblName,
+ email: lblEmail,
+ reference: lblRef,
+ notes: lblNotes
+ },
+ formVisible: {
+ name: visName,
+ email: visEmail,
+ reference: visRef,
+ notes: visNotes
+ },
+ uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
+ uploadExtWhitelist,
+ uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
+ showThankYou,
+ thankYouText,
+ };
});
+ if (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) {
status.textContent = 'Saving…';
status.className = 'small text-muted';
diff --git a/public/js/portal.js b/public/js/portal.js
index 079a791..1727aa1 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -10,10 +10,33 @@ function portalFolder() {
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() {
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') {
return !!portal.allowDownload;
}
@@ -21,7 +44,7 @@ function portalCanDownload() {
return !!portal.allowDownloads;
}
- // Fallback: uploadOnly = true => no downloads
+ // Legacy: uploadOnly = true => no downloads
if (typeof portal.uploadOnly !== 'undefined') {
return !portal.uploadOnly;
}
@@ -260,7 +283,7 @@ function setupPortalForm(slug) {
const formSection = qs('portalFormSection');
const uploadSection = qs('portalUploadSection');
- if (!portal || !portal.requireForm) {
+ if (!portal || !portal.requireForm || !portalCanUpload()) {
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
return;
@@ -549,11 +572,21 @@ function renderPortalInfo() {
}
}
+ const uploadsEnabled = portalCanUpload();
+ const downloadsEnabled = portalCanDownload();
+
if (subtitleEl) {
- const parts = [];
- if (portal.uploadOnly) parts.push('upload only');
- if (portalCanDownload()) parts.push('download allowed');
- subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
+ let text = '';
+ if (uploadsEnabled && downloadsEnabled) {
+ text = 'Upload & download';
+ } else if (uploadsEnabled && !downloadsEnabled) {
+ text = 'Upload only';
+ } else if (!uploadsEnabled && downloadsEnabled) {
+ text = 'Download only';
+ } else {
+ text = 'Access only';
+ }
+ subtitleEl.textContent = text;
}
if (footerEl) {
@@ -561,6 +594,26 @@ function renderPortalInfo() {
? 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();
const color = portal.brandColor && portal.brandColor.trim();
if (color) {
@@ -741,6 +794,13 @@ async function loadPortalFiles() {
// ----------------- Upload -----------------
async function uploadFiles(fileList) {
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) {
showToast('Please fill in your details before uploading.');
return;
@@ -900,11 +960,23 @@ async function uploadFiles(fileList) {
// ----------------- Upload UI wiring -----------------
function wireUploadUI() {
- const drop = qs('portalDropzone');
- const input = qs('portalFileInput');
+ const drop = qs('portalDropzone');
+ const input = qs('portalFileInput');
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());
input.addEventListener('change', (e) => {
@@ -938,10 +1010,15 @@ function wireUploadUI() {
});
}
+ // Download / refresh
if (refreshBtn) {
- refreshBtn.addEventListener('click', () => {
- loadPortalFiles();
- });
+ if (!downloadsEnabled) {
+ refreshBtn.style.display = 'none';
+ } else {
+ refreshBtn.addEventListener('click', () => {
+ loadPortalFiles();
+ });
+ }
}
}
diff --git a/public/portal.html b/public/portal.html
index 7815b5d..8914777 100644
--- a/public/portal.html
+++ b/public/portal.html
@@ -172,6 +172,11 @@
.portal-required-star {
color: #dc3545;
}
+ .portal-dropzone.portal-dropzone-disabled {
+ opacity: 0.5;
+ border-style: solid;
+ pointer-events: none;
+ }
diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php
index 9c7ac2a..e321efd 100644
--- a/src/controllers/AdminController.php
+++ b/src/controllers/AdminController.php
@@ -317,99 +317,103 @@ public function saveProPortals(array $portalsPayload): void
require_once $proPortalsPath;
- if (!is_array($portalsPayload)) {
- throw new InvalidArgumentException('Invalid portals format.');
- }
+ if (!is_array($portalsPayload)) {
+ throw new InvalidArgumentException('Invalid portals format.');
+ }
+
+ $data = ['portals' => []];
+ $invalid = [];
+
+ foreach ($portalsPayload as $slug => $info) {
+ $slug = trim((string)$slug);
+
+ if (!is_array($info)) {
+ $info = [];
+ }
+
+ $label = trim((string)($info['label'] ?? $slug));
+ $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'] ?? ''));
+ $uploadOnly = !empty($info['uploadOnly']);
+ $allowDownload = array_key_exists('allowDownload', $info)
+ ? !empty($info['allowDownload'])
+ : true;
+ $expiresAt = trim((string)($info['expiresAt'] ?? ''));
+
+ // Branding + form behavior
+ $title = trim((string)($info['title'] ?? ''));
+ $introText = trim((string)($info['introText'] ?? ''));
+ $requireForm = !empty($info['requireForm']);
+ $brandColor = trim((string)($info['brandColor'] ?? ''));
+ $footerText = trim((string)($info['footerText'] ?? ''));
+
+ // Optional logo info
+ $logoFile = trim((string)($info['logoFile'] ?? ''));
+ $logoUrl = trim((string)($info['logoUrl'] ?? ''));
+
+ // Upload rules / thank-you behavior
+ $uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
+ $uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
+ $uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
+ $showThankYou = !empty($info['showThankYou']);
+ $thankYouText = trim((string)($info['thankYouText'] ?? ''));
+
+ // Form defaults
+ $formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
+ ? $info['formDefaults']
+ : [];
+
+ $formDefaults = [
+ 'name' => trim((string)($formDefaults['name'] ?? '')),
+ 'email' => trim((string)($formDefaults['email'] ?? '')),
+ 'reference' => trim((string)($formDefaults['reference'] ?? '')),
+ 'notes' => trim((string)($formDefaults['notes'] ?? '')),
+ ];
+
+ // Required flags
+ $formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
+ ? $info['formRequired']
+ : [];
+
+ $formRequired = [
+ 'name' => !empty($formRequired['name']),
+ 'email' => !empty($formRequired['email']),
+ 'reference' => !empty($formRequired['reference']),
+ 'notes' => !empty($formRequired['notes']),
+ ];
+
+ // Labels
+ $formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
+ ? $info['formLabels']
+ : [];
+
+ $formLabels = [
+ 'name' => trim((string)($formLabels['name'] ?? 'Name')),
+ 'email' => trim((string)($formLabels['email'] ?? 'Email')),
+ 'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
+ 'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
+ ];
+
+ // Visibility
+ $formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
+ ? $info['formVisible']
+ : [];
+
+ $formVisible = [
+ 'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
+ 'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
+ 'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
+ 'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
+ ];
- $data = ['portals' => []];
- foreach ($portalsPayload as $slug => $info) {
- $slug = trim((string)$slug);
- if ($slug === '') {
- continue;
- }
- if (!is_array($info)) {
- $info = [];
- }
-
- $label = trim((string)($info['label'] ?? $slug));
- $folder = trim((string)($info['folder'] ?? ''));
- $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
- $title = trim((string)($info['title'] ?? ''));
- $introText = trim((string)($info['introText'] ?? ''));
- $requireForm = !empty($info['requireForm']);
- $brandColor = trim((string)($info['brandColor'] ?? ''));
- $footerText = trim((string)($info['footerText'] ?? ''));
-
- // Optional logo info
- $logoFile = trim((string)($info['logoFile'] ?? ''));
- $logoUrl = trim((string)($info['logoUrl'] ?? ''));
-
- // Upload rules / thank-you behavior
- $uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
- $uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
- $uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
- $showThankYou = !empty($info['showThankYou']);
- $thankYouText = trim((string)($info['thankYouText'] ?? ''));
-
- // Form defaults
- $formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
- ? $info['formDefaults']
- : [];
-
- $formDefaults = [
- 'name' => trim((string)($formDefaults['name'] ?? '')),
- 'email' => trim((string)($formDefaults['email'] ?? '')),
- 'reference' => trim((string)($formDefaults['reference'] ?? '')),
- 'notes' => trim((string)($formDefaults['notes'] ?? '')),
- ];
-
- // Required flags
- $formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
- ? $info['formRequired']
- : [];
-
- $formRequired = [
- 'name' => !empty($formRequired['name']),
- 'email' => !empty($formRequired['email']),
- 'reference' => !empty($formRequired['reference']),
- 'notes' => !empty($formRequired['notes']),
- ];
-
- // Labels
- $formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
- ? $info['formLabels']
- : [];
-
- $formLabels = [
- 'name' => trim((string)($formLabels['name'] ?? 'Name')),
- 'email' => trim((string)($formLabels['email'] ?? 'Email')),
- 'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
- 'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
- ];
-
- // Visibility
- $formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
- ? $info['formVisible']
- : [];
-
- $formVisible = [
- 'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
- 'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
- 'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
- 'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
- ];
-
- if ($folder === '') {
- continue;
- }
$data['portals'][$slug] = [
'label' => $label,
@@ -436,6 +440,12 @@ public function saveProPortals(array $portalsPayload): void
'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);
$ok = $store->savePortals($data);
diff --git a/src/controllers/PortalController.php b/src/controllers/PortalController.php
index c978ff4..f586253 100644
--- a/src/controllers/PortalController.php
+++ b/src/controllers/PortalController.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
+require_once PROJECT_ROOT . '/src/lib/ACL.php';
final class PortalController
{
@@ -11,29 +12,31 @@ final class PortalController
*
* Returns:
* [
- * 'slug' => string,
- * 'label' => string,
- * 'folder' => string,
- * 'clientEmail' => string,
- * 'uploadOnly' => bool,
- * 'allowDownload' => bool,
- * 'expiresAt' => string,
- * 'title' => string,
- * 'introText' => string,
- * 'requireForm' => bool,
- * 'brandColor' => string,
- * 'footerText' => string,
- * 'formDefaults' => array,
- * 'formRequired' => array,
- * 'formLabels' => array,
- * 'formVisible' => array,
- * 'logoFile' => string,
- * 'logoUrl' => string,
- * 'uploadMaxSizeMb' => int,
+ * 'slug' => string,
+ * 'label' => string,
+ * 'folder' => string,
+ * 'clientEmail' => string,
+ * 'uploadOnly' => bool, // stored flag (legacy name)
+ * 'allowDownload' => bool, // stored flag
+ * 'expiresAt' => string,
+ * 'title' => string,
+ * 'introText' => string,
+ * 'requireForm' => bool,
+ * 'brandColor' => string,
+ * 'footerText' => string,
+ * 'formDefaults' => array,
+ * 'formRequired' => array,
+ * 'formLabels' => array,
+ * 'formVisible' => array,
+ * 'logoFile' => string,
+ * 'logoUrl' => string,
+ * 'uploadMaxSizeMb' => int,
* 'uploadExtWhitelist' => string,
- * 'uploadMaxPerDay' => int,
- * 'showThankYou' => bool,
- * 'thankYouText' => string,
+ * 'uploadMaxPerDay' => int,
+ * 'showThankYou' => bool,
+ * 'thankYouText' => string,
+ * 'canUpload' => bool, // ACL + portal flags
+ * 'canDownload' => bool, // ACL + portal flags
* ]
*/
public static function getPortalBySlug(string $slug): array
@@ -66,21 +69,50 @@ final class PortalController
$p = $portals[$slug];
- $label = trim((string)($p['label'] ?? $slug));
- $folder = trim((string)($p['folder'] ?? ''));
- $clientEmail = trim((string)($p['clientEmail'] ?? ''));
- $uploadOnly = !empty($p['uploadOnly']);
- $allowDownload = array_key_exists('allowDownload', $p)
- ? !empty($p['allowDownload'])
- : true;
- $expiresAt = trim((string)($p['expiresAt'] ?? ''));
+ // ─────────────────────────────────────────────
+ // 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));
+ $folder = trim((string)($p['folder'] ?? ''));
+ $clientEmail = trim((string)($p['clientEmail'] ?? ''));
+
+ $expiresAt = trim((string)($p['expiresAt'] ?? ''));
// Branding + intake behavior
- $title = trim((string)($p['title'] ?? ''));
- $introText = trim((string)($p['introText'] ?? ''));
- $requireForm = !empty($p['requireForm']);
- $brandColor = trim((string)($p['brandColor'] ?? ''));
- $footerText = trim((string)($p['footerText'] ?? ''));
+ $title = trim((string)($p['title'] ?? ''));
+ $introText = trim((string)($p['introText'] ?? ''));
+ $requireForm = !empty($p['requireForm']);
+ $brandColor = trim((string)($p['brandColor'] ?? ''));
+ $footerText = trim((string)($p['footerText'] ?? ''));
// Defaults / required
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
@@ -134,11 +166,11 @@ final class PortalController
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
// Upload rules / thank-you behavior
- $uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
- $uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
- $uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
- $showThankYou = !empty($p['showThankYou']);
- $thankYouText = trim((string)($p['thankYouText'] ?? ''));
+ $uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
+ $uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
+ $uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
+ $showThankYou = !empty($p['showThankYou']);
+ $thankYouText = trim((string)($p['thankYouText'] ?? ''));
if ($folder === '') {
throw new RuntimeException('Portal misconfigured: empty folder.');
@@ -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 [
'slug' => $slug,
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
- 'uploadOnly' => $uploadOnly,
- 'allowDownload' => $allowDownload,
+ // Store flags as-is so old code / JSON stay compatible
+ 'uploadOnly' => (bool)$rawUploadOnly,
+ 'allowDownload' => $hasAllowDownload
+ ? (bool)$rawAllowDownload
+ : $allowDownload,
'expiresAt' => $expiresAt,
'title' => $title,
'introText' => $introText,
@@ -176,6 +243,9 @@ final class PortalController
'uploadMaxPerDay' => $uploadMaxPerDay,
'showThankYou' => $showThankYou,
'thankYouText' => $thankYouText,
+ // New ACL-aware caps for portal.js
+ 'canUpload' => $canUpload,
+ 'canDownload' => $canDownload,
];
}
}
\ No newline at end of file