Files
nmemmertandClaude Sonnet 5.5 f977f3294c
build / check (push) Successful in 11s
build / image (push) Successful in 27s
Add admin console for managing users; default account is admin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:53:40 -04:00

126 lines
7.6 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0f0f12">
<title>Admin · Audiobooks</title>
<style>
:root{color-scheme:light dark;--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--bd:rgba(0,0,0,.1);--red:#e03131}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--surf:#1a1a1f;--surf2:#26262d;--fg:#f2f2f4;--mut:#8f8f99;--acc:#ff7a2f;--bd:rgba(255,255,255,.1);--red:#ff6b6b}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;padding:calc(20px + env(safe-area-inset-top)) 16px 40px}
main{max-width:820px;margin:auto}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
h1{margin:0;font-size:24px;letter-spacing:-.02em}
a{color:var(--acc);text-decoration:none;font-weight:600}
.card{background:var(--surf);border:1px solid var(--bd);border-radius:16px;padding:18px;margin-bottom:18px}
.card h2{margin:0 0 12px;font-size:16px}
form.add{display:grid;grid-template-columns:1fr 1fr auto auto;gap:10px;align-items:center}
input[type=text],input[type=password]{width:100%;padding:11px 12px;border-radius:10px;border:1px solid var(--bd);background:var(--bg);color:var(--fg);font-size:16px;outline:0}
input:focus{border-color:var(--acc)}
label.chk{display:flex;gap:6px;align-items:center;color:var(--mut);white-space:nowrap}
button{font:inherit;cursor:pointer;border-radius:10px;border:1px solid var(--bd);background:var(--surf2);color:var(--fg);padding:9px 14px;font-weight:600}
button.pri{background:var(--acc);border-color:var(--acc);color:#fff}
button.danger{color:var(--red)}
button:disabled{opacity:.4;cursor:default}
.row{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--bd);flex-wrap:wrap}
.row:first-of-type{border-top:0}
.av{width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,var(--acc),#ffb067);color:#fff;display:grid;place-items:center;font-weight:800;text-transform:uppercase;flex:none}
.who{flex:1;min-width:140px}
.who b{display:flex;gap:8px;align-items:center}
.badge{font-size:11px;font-weight:700;background:var(--acc);color:#fff;border-radius:99px;padding:1px 8px}
.who small{color:var(--mut)}
.acts{display:flex;gap:8px;flex-wrap:wrap}
dialog{border:1px solid var(--bd);border-radius:16px;background:var(--surf);color:var(--fg);padding:22px;width:min(380px,92vw)}
dialog::backdrop{background:rgba(0,0,0,.55)}
dialog h3{margin:0 0 12px}
dialog .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
#err{color:var(--red);min-height:20px;margin-top:8px;font-size:14px}
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:10px 16px;border-radius:99px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .2s}
#toast.on{opacity:1}
@media(max-width:640px){form.add{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<header><h1>Admin</h1><a href="/">← Back to player</a></header>
<div class="card">
<h2>Add a user</h2>
<form class="add" id="add">
<input type="text" id="nu" placeholder="Username" autocapitalize="none" autocorrect="off" autocomplete="off" required>
<input type="password" id="np" placeholder="Password (6+ characters)" autocomplete="new-password" required>
<label class="chk"><input type="checkbox" id="na"> Admin</label>
<button class="pri">Add</button>
</form>
<div id="err"></div>
</div>
<div class="card">
<h2>Users <span id="n" style="color:var(--mut);font-weight:500"></span></h2>
<div id="list"></div>
</div>
</main>
<dialog id="pw"><form method="dialog" id="pwf">
<h3 id="pwt">Reset password</h3>
<input type="password" id="pwv" placeholder="New password (6+ characters)" autocomplete="new-password" required>
<div id="pwe" style="color:var(--red);min-height:20px;margin-top:6px;font-size:14px"></div>
<div class="btns"><button type="button" id="pwc">Cancel</button><button class="pri">Set password</button></div>
</form></dialog>
<div id="toast"></div>
<script>
const $ = id => document.getElementById(id);
let me = '', users = [], target = '';
const toast = t => { $('toast').textContent = t; $('toast').classList.add('on'); setTimeout(() => $('toast').classList.remove('on'), 2200); };
async function api(path, body) {
const r = await fetch(path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (r.status === 401) { location.href = '/login'; throw new Error('sign in'); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || r.status);
return j;
}
const ago = t => { if (!t) return 'never'; const s = (Date.now() - t) / 1000;
return s < 90 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
async function load() {
try { me = (await api('/me')).user; users = await api('/admin/users'); }
catch (e) { document.body.innerHTML = '<main><p>' + (e.message === 'admins only' ? 'This page is for admins only.' : 'Could not load: ' + e.message) + ' <a href="/">Back</a></p></main>'; return; }
$('n').textContent = users.length;
$('list').replaceChildren(...users.map(u => {
const d = document.createElement('div'); d.className = 'row';
d.innerHTML = `<div class="av"></div><div class="who"><b><span></span></b><small></small></div><div class="acts"></div>`;
d.querySelector('.av').textContent = u.name[0];
d.querySelector('b span').textContent = u.name + (u.name === me ? ' (you)' : '');
if (u.admin) d.querySelector('b').insertAdjacentHTML('beforeend', '<span class="badge">ADMIN</span>');
d.querySelector('small').textContent = `${u.books} book${u.books === 1 ? '' : 's'} started · last listened ${ago(u.last)}`;
const acts = d.querySelector('.acts');
const mk = (label, cls, fn, dis) => { const b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.disabled = !!dis; b.onclick = fn; acts.append(b); };
mk('Reset password', '', () => { target = u.name; $('pwt').textContent = 'New password for ' + u.name; $('pwv').value = ''; $('pwe').textContent = ''; $('pw').showModal(); $('pwv').focus(); });
mk(u.admin ? 'Remove admin' : 'Make admin', '', () => act(`/admin/users/${encodeURIComponent(u.name)}/admin`, { admin: !u.admin }, u.admin ? u.name + ' is no longer an admin' : u.name + ' is now an admin'));
mk('Delete', 'danger', () => { if (confirm(`Delete ${u.name}? Their listening progress is removed too.`)) act(`/admin/users/${encodeURIComponent(u.name)}/delete`, {}, 'Deleted ' + u.name); }, u.name === me);
return d;
}));
}
async function act(path, body, msg) { try { await api(path, body); toast(msg); await load(); } catch (e) { toast(e.message); } }
$('add').onsubmit = async e => {
e.preventDefault(); $('err').textContent = '';
try {
await api('/admin/users', { username: $('nu').value, password: $('np').value, admin: $('na').checked });
toast('Added ' + $('nu').value.toLowerCase()); $('add').reset(); await load();
} catch (e) { $('err').textContent = e.message; }
};
$('pwc').onclick = () => $('pw').close();
$('pwf').onsubmit = async e => {
e.preventDefault();
try { await api(`/admin/users/${encodeURIComponent(target)}/password`, { password: $('pwv').value }); $('pw').close(); toast('Password changed; ' + target + ' was signed out everywhere'); }
catch (e) { $('pwe').textContent = e.message; }
};
load();
</script>
</body>
</html>