User admin in the web UI, admin-only log, unread-first OPML feeds

- Settings > Manage users: add an account (password, or none for proxy
  sign-in), toggle admin, remove. Backed by GET/POST /api/users and
  PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
  cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
  the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
  sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
  the ipodderx service of the Arcane project content; CLAUDE.md and the
  README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
  non-admin and the unread ordering (new Aardvark Radio fixture); a unit
  test for last_admin; the smoke test drives usersModal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
2026-09-11 12:43:28 +00:00
parent 6114add4a6
commit 5e95557cbb
11 changed files with 370 additions and 40 deletions

View File

@@ -513,6 +513,9 @@ async function loadFeeds(keepSel){
renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0);
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
@@ -527,7 +530,8 @@ function renderFeeds(){
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
// opens them all, or matches inside a closed folder would be invisible.
if(expanded.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]);
if(expanded.has(f.id) || q)
for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]);
}
for(const [f,depth] of order){
const kids=shown.filter(c=>c.group===f.id).length;
@@ -646,7 +650,7 @@ function renderGroup(f,kids){
const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML='';
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q));
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
for(const c of rows){
const el=document.createElement('div');
@@ -1227,9 +1231,13 @@ async function prefsModal(){
</div>
<span class="hint">Export hands every subscription to another podcast app. Import adds the
feeds listed in an OPML you paste in.</span></div>
<div class="field"><label>Users</label>
<div class="inline"><button class="btn" id="gusers">Manage users…</button></div>
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="gsave">Save</button></div>`);
$('#gopml').onclick=opmlModal;
$('#gusers').onclick=usersModal;
$('#gsave').onclick=async()=>{
try{
await api('/api/settings',{method:'PATCH',body:JSON.stringify({
@@ -1244,6 +1252,52 @@ async function prefsModal(){
};
}
// Admin only, and the server enforces that: this screen is just the way in.
async function usersModal(){
const users = await api('/api/users') || [];
openModal(`<h3>Users</h3>
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
<button class="btn danger" data-a="rm">Remove</button></div>`).join('')}
<div class="field" style="margin-top:16px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
<button class="btn primary" id="uadd">Add</button></div>`);
const change=async(u,opts)=>{
try{
await api(`/api/users/${u.id}`,opts);
// Demoting yourself takes this screen away; reload so the page stops offering it. Only
// on success: reloading after a refusal wiped the toast that said why.
if(u.name===S.me?.name){ location.reload(); return; }
}catch(e){ toast(e.message,true); }
usersModal(); // on a refusal, this puts the checkbox back where the server left it
};
$$('#modalCard [data-id]').forEach(row=>{
const u=users.find(x=>String(x.id)===row.dataset.id);
$('[data-a="admin"]',row).onchange=e=>
change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})});
$('[data-a="rm"]',row).onclick=()=>{
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u,{method:'DELETE'});
};
});
$('#uadd').onclick=async()=>{
try{
await api('/api/users',{method:'POST',body:JSON.stringify({
name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})});
toast('Added'); usersModal();
}catch(e){ toast(e.message,true); } // keep what was typed
};
}
function settingsModal(f){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
@@ -1360,8 +1414,9 @@ on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); loc
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
// Scanning, quotas and the download folder are the operator's business.
if(!u.admin) $('#prefs').hidden=true;
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
// them too; hiding the buttons just stops offering what would fail.
if(!u.admin){ $('#prefs').hidden=true; $('#logs').hidden=true; }
}).catch(()=>{});
on('#logs','onclick',logsModal);
$('#feedFilter').oninput=renderFeeds;