release(v2.3.5): make client portals ACL-aware and improve admin UX
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,9 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/5/2025 (v2.3.3)
|
||||
|
||||
release(v2.3.3): footer branding, Pro bundle UX + file list polish
|
||||
|
||||
@@ -58,6 +58,27 @@ try {
|
||||
require_once $subPath;
|
||||
|
||||
$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 = [
|
||||
'slug' => $slug,
|
||||
'portalLabel' => $portal['label'] ?? '',
|
||||
@@ -69,7 +90,7 @@ try {
|
||||
'notes' => $notes,
|
||||
],
|
||||
'submittedBy' => $submittedBy,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'ip' => $ip,
|
||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'createdAt' => gmdate('c'),
|
||||
];
|
||||
|
||||
@@ -1968,7 +1968,7 @@ export function openUserPermissionsModal() {
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 3500;
|
||||
z-index: 10000;
|
||||
`;
|
||||
userPermissionsModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
|
||||
@@ -217,6 +217,9 @@ let __portalsCache = {};
|
||||
let __portalFolderListLoaded = false;
|
||||
let __portalFolderOptions = [];
|
||||
|
||||
// Remember a newly-created portal to focus its folder field
|
||||
let __portalSlugToFocus = null;
|
||||
|
||||
// Cache portal submissions per slug for CSV export
|
||||
const __portalSubmissionsCache = {};
|
||||
|
||||
@@ -279,13 +282,38 @@ export async function openClientPortalsModal() {
|
||||
(and optionally download) files without seeing your full FileRise UI.
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
||||
<span style="margin-left:4px;">Add portal</span>
|
||||
</button>
|
||||
<span id="clientPortalsStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
<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">
|
||||
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
||||
<span style="margin-left:4px;">Add portal</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
id="clientPortalsQuickAddUser"
|
||||
class="btn btn-sm btn-outline-primary ms-1">
|
||||
<i class="material-icons" style="font-size:16px; vertical-align:middle;">person_add</i>
|
||||
<span style="margin-left:4px;">Add user…</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="clientPortalsOpenUserPerms"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">
|
||||
<i class="material-icons" style="font-size:16px; vertical-align:middle;">folder_shared</i>
|
||||
<span style="margin-left:4px;">Folder access…</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="clientPortalsOpenUserGroups"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">
|
||||
<i class="material-icons" style="font-size:16px; vertical-align:middle;">groups</i>
|
||||
<span style="margin-left:4px;">User groups…</span>
|
||||
</button>
|
||||
</div>
|
||||
<span id="clientPortalsStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
|
||||
<div id="clientPortalsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
|
||||
${t('loading')}…
|
||||
@@ -303,6 +331,41 @@ export async function openClientPortalsModal() {
|
||||
document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
|
||||
document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
|
||||
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 {
|
||||
modal.style.background = overlayBg;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
@@ -358,7 +421,22 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
const folder = p.folder || '';
|
||||
const clientEmail = p.clientEmail || '';
|
||||
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 brandColor = p.brandColor || '';
|
||||
const footerText = p.footerText || '';
|
||||
@@ -419,8 +497,8 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
|
||||
<div class="portal-card-body">
|
||||
<div class="portal-meta-row">
|
||||
<label style="font-weight:600;">
|
||||
Portal slug:
|
||||
<label style="font-weight:600;">
|
||||
Portal slug<span class="text-danger">*</span>:
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
data-portal-field="slug"
|
||||
@@ -439,8 +517,8 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
|
||||
<div class="portal-meta-row">
|
||||
<div class="portal-folder-row">
|
||||
<label>
|
||||
Folder:
|
||||
<label>
|
||||
Folder<span class="text-danger">*</span>:
|
||||
<input type="text"
|
||||
class="form-control form-control-sm portal-folder-input"
|
||||
data-portal-field="folder"
|
||||
@@ -482,11 +560,11 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:4px;">
|
||||
<label style="display:flex; align-items:center; gap:4px;">
|
||||
<input type="checkbox"
|
||||
data-portal-field="uploadOnly"
|
||||
${uploadOnly ? 'checked' : ''} />
|
||||
<span>Upload only</span>
|
||||
<span>Allow upload</span>
|
||||
</label>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:4px;">
|
||||
@@ -495,7 +573,6 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
${allowDownload ? 'checked' : ''} />
|
||||
<span>Allow download</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px;">
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@
|
||||
.portal-required-star {
|
||||
color: #dc3545;
|
||||
}
|
||||
.portal-dropzone.portal-dropzone-disabled {
|
||||
opacity: 0.5;
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@@ -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' => []];
|
||||
$data = ['portals' => []];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
|
||||
$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'] ?? ''));
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
// 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'] ?? ''));
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$folder = trim((string)($info['folder'] ?? ''));
|
||||
|
||||
// Optional logo info
|
||||
$logoFile = trim((string)($info['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
|
||||
// Require both slug and folder; collect invalid ones so the UI can warn.
|
||||
if ($slug === '' || $folder === '') {
|
||||
$invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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'] ?? ''));
|
||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
|
||||
// Form defaults
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
// Branding + form behavior
|
||||
$title = trim((string)($info['title'] ?? ''));
|
||||
$introText = trim((string)($info['introText'] ?? ''));
|
||||
$requireForm = !empty($info['requireForm']);
|
||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
// Optional logo info
|
||||
$logoFile = trim((string)($info['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
|
||||
|
||||
// Required flags
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
// 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'] ?? ''));
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($formRequired['name']),
|
||||
'email' => !empty($formRequired['email']),
|
||||
'reference' => !empty($formRequired['reference']),
|
||||
'notes' => !empty($formRequired['notes']),
|
||||
];
|
||||
// Form defaults
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
// Labels
|
||||
$formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
|
||||
? $info['formLabels']
|
||||
: [];
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
|
||||
$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')),
|
||||
];
|
||||
// Required flags
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
|
||||
// Visibility
|
||||
$formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
|
||||
? $info['formVisible']
|
||||
: [];
|
||||
$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']),
|
||||
];
|
||||
|
||||
$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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user