QOL improvements: folder search, user audit, admin folders fix, AMPP debug

- Add search/filter input to destination folder browser with 320px
  scrollable container
- Add User Audit button showing each user's visible pages, role,
  quota, and folder access permissions
- Fix admin Folders tab delete buttons (were broken due to
  JSON.stringify quote conflicts in innerHTML onclick handlers)
- Add more AMPP job name field fallbacks and debug logging to
  diagnose asset name display issue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-07 01:03:28 -04:00
parent ad5f9a4186
commit 0f81cac6ec

View file

@ -474,7 +474,8 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
<div style="margin-bottom:1.5rem">
<div class="section-title">Destination Folder</div>
<div class="folder-tree-box" id="folder-tree-box"></div>
<input class="form-input" id="folder-search" placeholder="🔍 Search folders…" oninput="renderFolderTree()" style="margin-bottom:.4rem;font-size:.78rem;padding:.4rem .65rem"/>
<div class="folder-tree-box" id="folder-tree-box" style="max-height:320px;overflow-y:auto"></div>
<div class="add-row" style="margin-top:.55rem" id="add-folder-row">
<input class="add-input" id="new-folder-input" placeholder="New folder name…" onkeydown="if(event.key==='Enter')addFolder()"/>
<button class="btn-small" onclick="addFolder()">+ Add</button>
@ -671,6 +672,17 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
<div class="status-msg" id="perm-status"></div>
</div>
</div>
<!-- User Audit modal -->
<div id="audit-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;align-items:center;justify-content:center">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:2rem;width:520px;max-width:95vw;max-height:90vh;overflow-y:auto">
<div class="section-title" id="audit-modal-title">User Audit</div>
<div id="audit-content" style="margin-top:1rem"></div>
<div class="btn-row" style="margin-top:1.5rem">
<button class="btn-secondary" onclick="document.getElementById('audit-modal').style.display='none'">Close</button>
</div>
</div>
</div>
</div>
<!-- Share Links -->
@ -970,14 +982,29 @@ function renderFolderTree() {
const box = document.getElementById('folder-tree-box');
if (!box) return;
box.innerHTML = '';
// Root row
const rootRow = document.createElement('div');
rootRow.className = 'folder-tree-row' + (selectedPrefix==='' ? ' active' : '');
rootRow.innerHTML = `<span class="ftr-icon">🏠</span><span class="ftr-name" style="font-style:${selectedPrefix===''?'normal':'italic'};color:${selectedPrefix===''?'':'var(--text-dim)'}">Root (no folder)</span>`;
rootRow.onclick = () => { selectedPrefix=''; updatePrefixDisplay(); renderFolderTree(); };
box.appendChild(rootRow);
const searchEl = document.getElementById('folder-search');
const filter = (searchEl ? searchEl.value.trim().toLowerCase() : '');
// Helper: does a node (or any descendant) match the filter?
function matchesFilter(node, pathArr) {
const key = [...pathArr, node.name].join('/');
if (key.toLowerCase().includes(filter)) return true;
if (node.children) return node.children.some(c => matchesFilter(c, [...pathArr, node.name]));
return false;
}
// Root row (always shown unless filtering)
if (!filter) {
const rootRow = document.createElement('div');
rootRow.className = 'folder-tree-row' + (selectedPrefix==='' ? ' active' : '');
rootRow.innerHTML = `<span class="ftr-icon">🏠</span><span class="ftr-name" style="font-style:${selectedPrefix===''?'normal':'italic'};color:${selectedPrefix===''?'':'var(--text-dim)'}">Root (no folder)</span>`;
rootRow.onclick = () => { selectedPrefix=''; updatePrefixDisplay(); renderFolderTree(); };
box.appendChild(rootRow);
}
function addRows(nodes, pathArr, container) {
nodes.forEach(n => {
if (filter && !matchesFilter(n, pathArr)) return;
const fullPath = [...pathArr, n.name];
const key = fullPath.join('/');
const indent = pathArr.length;
@ -999,6 +1026,10 @@ function renderFolderTree() {
});
}
addRows(folderTree, [], box);
if (!box.children.length) {
box.innerHTML = '<div style="color:var(--text-dim);font-size:.8rem;padding:.75rem;text-align:center">No folders match your search</div>';
}
}
// Legacy aliases so other code still works
@ -1024,7 +1055,7 @@ async function deleteFolder(pathArr) {
try {
await api('POST','/api/folders/delete',{path:pathArr});
if (selectedPrefix.startsWith(pathArr.join('/'))) { selectedPrefix=''; updatePrefixDisplay(); }
await loadFolders(); showToast('Folder deleted','success');
await loadFolders(); await loadAdminFolders(); showToast('Folder deleted','success');
} catch(e) { showToast(e.message,'error'); }
}
@ -1218,14 +1249,17 @@ async function loadAmppJobs() {
list.innerHTML='<div style="color:var(--text-dim);text-align:center;padding:2rem;font-size:.85rem">No jobs in queue</div>';
return;
}
// Log first job's keys for debugging field names
if (jobs.length) console.log('[AMPP] Sample job keys:', Object.keys(jobs[0]), 'Full:', JSON.stringify(jobs[0]).substring(0, 500));
jobs.forEach(job => {
const el=document.createElement('div'); el.className='job-item';
// AMPP API uses colon-namespaced keys e.g. "state:jobState", "name:text", "job:id"
const st=(job['state:jobState']||job.status||job.state||job.jobStatus||'unknown').toLowerCase();
const cls=st.includes('run')||st.includes('active')?'running':st.includes('complet')||st.includes('success')||st.includes('done')?'completed':st.includes('fail')||st.includes('error')||st.includes('abort')?'failed':st.includes('queue')||st.includes('wait')||st.includes('pend')?'queued':'unknown';
const name=job['name:text']||job['assetName:text']||job.name||job.displayName||job['job:id']||job.id||'Job';
// Try many possible asset name fields — AMPP responses vary by job type
const name=job['name:text']||job['assetName:text']||job['source:text']||job['sourceFile:text']||job['inputFile:text']||job['input:text']||job.name||job.displayName||job.assetName||job.sourceName||job.sourceFile||job.inputFile||job['job:id']||job.id||'Job';
const jobType=(job['type:jobType']||job['subtype:jobSubtype']||job.type||job.jobType||'').replace(/([A-Z])/g,' $1').trim();
const creator=job['creator:id']||'';
const creator=job['creator:id']||job.creator||'';
const created=job['created:dateTime']||job.created||'';
const meta=[created?new Date(created).toLocaleString():'', jobType, creator].filter(Boolean).join(' · ');
el.innerHTML=`<div class="job-dot ${cls}"></div><div class="job-info"><div class="job-name">${esc(name)}</div><div class="job-meta">${esc(meta)}</div></div><span class="job-status ${cls}">${cls.charAt(0).toUpperCase()+cls.slice(1)}</span>`;
@ -1352,6 +1386,7 @@ async function loadUsers() {
<td style="color:var(--text-dim);font-size:.73rem;font-family:'JetBrains Mono',monospace">${u.created?new Date(u.created).toLocaleDateString():''}</td>
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="openPermissions('${esc(u.username)}')">⚙ Perms</button>
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="openUserAudit('${esc(u.username)}')">👁 Audit</button>
${u.username!==currentUser?`<button class="btn-danger" style="padding:.22rem .55rem;font-size:.68rem" onclick="deleteUser('${esc(u.username)}')">Delete</button>`:'<span style="color:var(--text-dim);font-size:.73rem">(you)</span>'}
</td>`;
tbody.appendChild(tr);
@ -1452,6 +1487,83 @@ function fmtBytes(b) {
return (b/1073741824).toFixed(2) + ' GB';
}
// ============================================================
// USER AUDIT
// ============================================================
async function openUserAudit(username) {
const modal = document.getElementById('audit-modal');
modal.style.display = 'flex';
document.getElementById('audit-modal-title').textContent = `Audit — ${username}`;
const content = document.getElementById('audit-content');
content.innerHTML = '<div style="color:var(--text-dim);font-size:.82rem">Loading…</div>';
try {
const [pd, fd] = await Promise.all([
api('GET', `/api/users/${encodeURIComponent(username)}/permissions`),
api('GET', '/api/folders')
]);
if (!pd.success) throw new Error(pd.error);
const allFolders = flattenFolders(fd.tree || []);
const allowed = pd.allowedFolders || [];
const hasRestrictions = allowed.length > 0;
const visibleFolders = hasRestrictions ? allFolders.filter(f => allowed.some(a => f === a || f.startsWith(a + '--'))) : allFolders;
let html = '';
// Role & Quota
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:1.25rem">`;
html += `<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:.75rem">
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.25rem">Role</div>
<div style="font-size:.88rem;font-weight:600"><span class="role-badge ${pd.role||'user'}">${pd.role||'user'}</span></div>
</div>`;
html += `<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:.75rem">
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.25rem">Upload Quota</div>
<div style="font-size:.88rem;font-weight:600">${pd.quotaMB ? `${fmtBytes(pd.uploadedBytes||0)} / ${pd.quotaMB} MB` : 'Unlimited'}</div>
</div>`;
html += `</div>`;
// Visible pages
const pages = ['Upload'];
if (pd.role === 'admin') pages.push('AMPP Monitor', 'Admin Panel');
else pages.push('AMPP Monitor');
html += `<div style="margin-bottom:1.25rem">
<div style="font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.5rem">Visible Pages</div>
<div style="display:flex;gap:.4rem;flex-wrap:wrap">${pages.map(p => `<span style="background:var(--accent-glow);color:var(--blue-bright);padding:.2rem .6rem;border-radius:6px;font-size:.75rem;font-weight:500">${p}</span>`).join('')}</div>
</div>`;
// Admin features
if (pd.role === 'admin') {
html += `<div style="margin-bottom:1.25rem">
<div style="font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.5rem">Admin Capabilities</div>
<div style="font-size:.8rem;color:var(--text-secondary);line-height:1.6">
S3 Storage settings, AMPP config, User management, Share Links, Folder management, Add/delete folders on upload page
</div>
</div>`;
}
// Folder access
html += `<div>
<div style="font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.5rem">Folder Access ${hasRestrictions ? '<span style="color:var(--warning)">(restricted)</span>' : '<span style="color:var(--success)">(all folders)</span>'}</div>
<div style="max-height:200px;overflow-y:auto;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:.5rem">`;
if (!visibleFolders.length) {
html += '<div style="color:var(--text-dim);font-size:.78rem;padding:.25rem">No folders configured</div>';
} else {
visibleFolders.forEach(f => {
const depth = (f.match(/--/g) || []).length;
const name = f.includes('--') ? f.split('--').pop() : f;
const isAllowed = !hasRestrictions || allowed.includes(f);
html += `<div style="padding:.2rem .4rem .2rem ${.4 + depth * 1}rem;font-family:'JetBrains Mono',monospace;font-size:.75rem;color:${isAllowed ? 'var(--text-secondary)' : 'var(--text-dim)'};display:flex;align-items:center;gap:.4rem">
<span>${isAllowed ? '✅' : '🚫'}</span> ${esc(name)}
</div>`;
});
}
html += `</div></div>`;
content.innerHTML = html;
} catch(e) {
content.innerHTML = `<div style="color:var(--error);font-size:.82rem">Error: ${e.message}</div>`;
}
}
// ============================================================
// SHARE LINKS
// ============================================================
@ -1540,7 +1652,14 @@ function renderAdminFolderTree(nodes,container,pathArr) {
nodes.forEach(n=>{
const fp=[...pathArr,n.name];
const div=document.createElement('div');div.style.paddingLeft=pathArr.length*20+'px';div.style.marginBottom='.3rem';
div.innerHTML=`<div style="display:flex;align-items:center;gap:.5rem;padding:.3rem .5rem;border:1px solid var(--border);border-radius:7px;background:var(--bg-card)"><span style="font-size:.82rem">${n.children.length?'📁':'📄'}</span><span style="font-family:'JetBrains Mono',monospace;font-size:.78rem;flex:1">${esc(n.name)}</span><button class="btn-danger" style="padding:.18rem .48rem;font-size:.66rem" onclick="deleteFolder(${JSON.stringify(fp)})">Delete</button></div>`;
const row=document.createElement('div');
row.style.cssText='display:flex;align-items:center;gap:.5rem;padding:.3rem .5rem;border:1px solid var(--border);border-radius:7px;background:var(--bg-card)';
row.innerHTML=`<span style="font-size:.82rem">${n.children.length?'📁':'📄'}</span><span style="font-family:'JetBrains Mono',monospace;font-size:.78rem;flex:1">${esc(n.name)}</span>`;
const delBtn=document.createElement('button');
delBtn.className='btn-danger';delBtn.style.cssText='padding:.18rem .48rem;font-size:.66rem';delBtn.textContent='Delete';
delBtn.onclick=()=>{ deleteFolder(fp).then(()=>{loadAdminFolders();}); };
row.appendChild(delBtn);
div.appendChild(row);
container.appendChild(div);
if(n.children.length){const sub=document.createElement('div');renderAdminFolderTree(n.children,sub,fp);container.appendChild(sub);}
});