← back to Secrets Manager
update-viewer/index.html
161 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Credentials & Tokens — Update Tracker</title>
<style>
:root{--bg:#0d0f13;--panel:#161a21;--ink:#e9e7e2;--muted:#8b919c;--rule:#262b34;--p1:#ef4444;--p2:#fbbf24;--ok:#4ade80;--accent:#9c7a4d}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font-family:'Inter',system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased;padding:0 0 60px}
header{position:sticky;top:0;z-index:5;background:rgba(13,15,19,.95);backdrop-filter:blur(8px);border-bottom:1px solid var(--rule);padding:18px 28px}
h1{font-family:'Cormorant Garamond',Georgia,serif;font-weight:600;font-size:27px;letter-spacing:.02em}
.sub{color:var(--muted);font-size:13px;margin-top:3px}
.counts{display:flex;gap:10px;margin-top:12px;flex-wrap:wrap}
.pill{font-size:12px;padding:4px 11px;border-radius:999px;border:1px solid var(--rule);color:var(--muted)}
.pill b{color:var(--ink)}
.wrap{max-width:980px;margin:24px auto;padding:0 24px;display:flex;flex-direction:column;gap:16px}
.card{background:var(--panel);border:1px solid var(--rule);border-radius:12px;padding:18px 20px;border-left:4px solid var(--rule)}
.card.P1{border-left-color:var(--p1)} .card.P2{border-left-color:var(--p2)} .card.prep{border-left-color:var(--muted)} .card.done{opacity:.62}
.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.title{font-size:17px;font-weight:600}
.sev{font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 8px;border-radius:5px;border:1px solid currentColor}
.sev.P1{color:var(--p1)}.sev.P2{color:var(--p2)}.sev.prep,.sev.P3{color:var(--muted)}
.type{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
.togg{margin-left:auto;font-size:12px;font-weight:600;padding:6px 13px;border-radius:7px;border:1px solid var(--rule);background:transparent;color:var(--muted);cursor:pointer}
.togg.outstanding{color:var(--p2);border-color:#fbbf2455}
.togg.done{color:var(--ok);border-color:#4ade8055;background:#4ade8011}
.gates{margin-top:8px;font-size:12px;color:var(--p1);background:#ef444411;border:1px solid #ef444433;border-radius:6px;padding:6px 10px;display:inline-block}
.exposed{margin-top:10px;font-size:12px;color:var(--muted);line-height:1.5}
.exposed b{color:#cbb}
.steps{margin-top:12px;display:flex;flex-direction:column;gap:7px}
.step{display:flex;gap:9px;align-items:flex-start;font-size:12px;color:#c7ccd4}
.step .n{color:var(--accent);font-weight:700;flex-shrink:0}
.cmd{margin-top:6px;background:#0a0c10;border:1px solid var(--rule);border-radius:6px;padding:9px 11px;font-family:'SF Mono',ui-monospace,monospace;font-size:11.5px;color:#9fd;white-space:pre-wrap;word-break:break-all;position:relative;cursor:pointer}
.cmd:hover{border-color:var(--accent)}
.cmd .lbl{position:absolute;top:6px;right:9px;font-size:9px;color:var(--muted);letter-spacing:.1em;text-transform:uppercase}
.mint{display:inline-block;margin-top:10px;font-size:12px;color:#7dd3fc;text-decoration:none;border:1px solid #7dd3fc44;border-radius:6px;padding:5px 11px}
.mint:hover{background:#7dd3fc11}
.runnable{margin-top:10px;font-size:11px;color:var(--ok)}
.row-label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin:14px 0 2px}
.catalog-shell{max-width:1400px;margin:34px auto 0;padding:0 24px}
.catalog-head{display:flex;align-items:end;justify-content:space-between;gap:18px;margin-bottom:12px;flex-wrap:wrap}
.catalog-head h2{font-family:'Cormorant Garamond',Georgia,serif;font-size:25px}
.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.controls input,.controls select{background:#11151b;color:var(--ink);border:1px solid var(--rule);border-radius:7px;padding:7px 9px}
.controls input[type=search]{width:260px}.controls input[type=range]{padding:0;width:110px}
.cred-grid{--cols:3;display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:10px}
.cred{background:var(--panel);border:1px solid var(--rule);border-radius:10px;padding:13px;min-width:0}
.cred.missing{border-left:3px solid var(--p1)}.cred.ready{border-left:3px solid var(--ok)}
.cred-name{font:600 12px 'SF Mono',ui-monospace,monospace;overflow-wrap:anywhere}.cred-label{font-size:12px;color:#bdc3ce;margin:6px 0;line-height:1.35}
.meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.meta span{font-size:10px;color:var(--muted);border:1px solid var(--rule);border-radius:999px;padding:2px 7px}
.when{display:block;font-size:10px;color:var(--muted);margin-top:8px}.cred-actions{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.cred-actions button{cursor:pointer;background:#0a0c10;color:#9fd;border:1px solid var(--rule);border-radius:6px;padding:5px 8px;font-size:10px}
@media(max-width:900px){.cred-grid{grid-template-columns:1fr!important}.controls input[type=search]{width:100%}}
</style>
</head>
<body>
<header>
<h1>Credentials & Tokens — Update Tracker</h1>
<div class="sub" id="origin">loading…</div>
<div class="counts" id="counts"></div>
</header>
<div class="wrap" id="wrap"></div>
<section class="catalog-shell">
<div class="catalog-head"><div><h2>Canonical credential registry</h2><div class="sub">Metadata only—secret values never enter this page.</div></div>
<div class="controls">
<input id="credSearch" type="search" placeholder="Search credentials…">
<select id="credStatus"><option value="all">All</option><option value="missing">Missing</option><option value="ready">Configured</option></select>
<select id="credSort"><option value="status">Missing first</option><option value="updated">Recently updated</option><option value="name">Name A→Z</option><option value="routes">Most destinations</option></select>
<label class="sub">Density <input id="credDensity" type="range" min="1" max="5" value="3"></label>
</div>
</div>
<div class="counts" id="credCounts"></div>
<div class="cred-grid" id="credGrid"></div>
</section>
<script>
const esc=s=>String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
const fmtDate=v=>v?new Date(v).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'Never registered';
const copy=t=>navigator.clipboard&&navigator.clipboard.writeText(t);
function cmdBox(label,txt){ if(!txt)return ''; return '<div class="cmd" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.dataset.c)" data-c="'+esc(txt)+'"><span class="lbl">'+label+' · copy</span>'+esc(txt)+'</div>'; }
async function load(){
const d=await (await fetch('/api/items')).json();
document.getElementById('origin').textContent=d.origin;
const out=d.items.filter(i=>i.status==='outstanding');
const p1=out.filter(i=>i.severity==='P1').length;
document.getElementById('counts').innerHTML=
'<span class="pill"><b>'+out.length+'</b> outstanding</span>'+
'<span class="pill" style="color:var(--p1)"><b>'+p1+'</b> P1 (blocks Kamatera)</span>'+
'<span class="pill"><b>'+d.items.filter(i=>i.status==='done').length+'</b> done</span>';
const w=document.getElementById('wrap'); w.innerHTML='';
for(const it of d.items){
const div=document.createElement('div');
div.className='card '+it.severity+(it.status==='done'?' done':'');
const steps=(it.console||[]).map((s,i)=>'<div class="step"><span class="n">'+(i+1)+'</span><span>'+esc(s)+'</span></div>').join('');
div.innerHTML=
'<div class="top">'+
'<span class="sev '+it.severity+'">'+esc(it.severity)+'</span>'+
'<span class="title">'+esc(it.title)+'</span>'+
'<span class="type">'+esc(it.type)+'</span>'+
'<button class="togg '+it.status+'" data-id="'+it.id+'" data-st="'+it.status+'">'+(it.status==='done'?'✓ done':'mark done')+'</button>'+
'</div>'+
(it.gates?'<div class="gates">⛔ gates: '+esc(it.gates)+'</div>':'')+
(it.exposed?'<div class="exposed"><b>Exposed:</b> '+esc(it.exposed)+'</div>':'')+
(steps?'<div class="row-label">Console (Steve)</div><div class="steps">'+steps+'</div>':'')+
(it.mintUrl?'<a class="mint" target="_blank" rel="noopener" href="'+esc(it.mintUrl)+'">↗ mint new key</a>':'')+
(it.routeBack?'<div class="row-label">Route back</div>'+cmdBox('route',it.routeBack):'')+
(it.restart?'<div class="row-label">Restart</div>'+cmdBox('restart',it.restart):'')+
(it.verify?'<div class="row-label">Verify</div>'+cmdBox('verify',it.verify):'')+
((it.claudeCanRun&&it.claudeCanRun.length)?'<div class="runnable">⚡ Claude can run: '+it.claudeCanRun.join(', ')+'</div>':'');
w.appendChild(div);
}
// ── To configure (not-set keys) ──
const tc = d.toConfigure || [];
if (tc.length) {
const h = document.createElement('div');
h.className = 'row-label';
h.style.cssText = 'margin:18px 4px 2px;font-size:13px;color:#cbb';
h.textContent = 'To configure — ' + tc.length + ' keys not set';
w.appendChild(h);
for (const k of tc) {
const c = document.createElement('div');
c.className = 'card prep';
c.style.padding = '13px 18px';
c.innerHTML =
'<div class="top"><span class="sev prep">not set</span><span class="title" style="font-size:15px">' + esc(k.name) + '</span></div>' +
'<div class="exposed">' + esc(k.desc) + '</div>' +
(k.mintUrl ? '<a class="mint" target="_blank" rel="noopener" href="' + esc(k.mintUrl) + '">↗ get key</a>' : '') +
cmdBox('route', "printf '" + k.name + "=<VALUE>' | node ~/Projects/secrets-manager/cli.js import-paste");
w.appendChild(c);
}
}
w.querySelectorAll('.togg').forEach(b=>b.onclick=async()=>{
const ns=b.dataset.st==='done'?'outstanding':'done';
await fetch('/api/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:b.dataset.id,status:ns})});
load();
});
}
load();
let CREDS=[];
function renderCreds(){
const q=document.getElementById('credSearch').value.trim().toLowerCase();
const status=document.getElementById('credStatus').value;
const sort=document.getElementById('credSort').value;
let rows=CREDS.filter(x=>(status==='all'||(status==='ready')===x.configured)&&(!q||(x.name+' '+x.label).toLowerCase().includes(q)));
rows.sort((a,b)=>sort==='name'?a.name.localeCompare(b.name):sort==='updated'?String(b.lastUpdated||'').localeCompare(String(a.lastUpdated||'')):sort==='routes'?b.destinationCount-a.destinationCount:Number(a.configured)-Number(b.configured)||a.name.localeCompare(b.name));
document.getElementById('credCounts').innerHTML='<span class="pill"><b>'+CREDS.length+'</b> total</span><span class="pill"><b>'+CREDS.filter(x=>x.configured).length+'</b> configured</span><span class="pill" style="color:var(--p1)"><b>'+CREDS.filter(x=>!x.configured).length+'</b> missing</span><span class="pill"><b>'+rows.length+'</b> shown</span>';
document.getElementById('credGrid').innerHTML=rows.map(x=>{
const add="node ~/Projects/secrets-manager/cli.js add "+x.name+" '<PASTE_VALUE>'";
const check='node ~/Projects/secrets-manager/cli.js check '+x.name;
return '<article class="cred '+(x.configured?'ready':'missing')+'"><div class="cred-name">'+esc(x.name)+'</div><div class="cred-label">'+esc(x.label)+'</div><div class="meta"><span>'+(x.configured?'✓ configured':'● missing')+'</span><span>'+x.destinationCount+' destinations</span><span>'+(x.hasVerifier?'verify available':'manual verify')+'</span>'+(x.digest?'<span>'+esc(x.digest)+'</span>':'')+'</div><time class="when" title="'+esc(x.lastUpdated||'')+'">🕓 '+esc(fmtDate(x.lastUpdated))+'</time><div class="cred-actions"><button onclick="copy('+JSON.stringify(add).replace(/"/g,'"')+')">Copy add command</button><button onclick="copy('+JSON.stringify(check).replace(/"/g,'"')+')">Copy check</button></div></article>';
}).join('');
}
async function loadCreds(){CREDS=(await (await fetch('/api/catalog')).json()).items;renderCreds();}
for(const id of ['credSearch','credStatus','credSort'])document.getElementById(id).addEventListener(id==='credSearch'?'input':'change',e=>{localStorage.setItem('secrets-'+id,e.target.value);renderCreds();});
for(const id of ['credStatus','credSort']){const v=localStorage.getItem('secrets-'+id);if(v)document.getElementById(id).value=v;}
const density=document.getElementById('credDensity');density.value=localStorage.getItem('secrets-density')||3;document.getElementById('credGrid').style.setProperty('--cols',density.value);density.oninput=e=>{document.getElementById('credGrid').style.setProperty('--cols',e.target.value);localStorage.setItem('secrets-density',e.target.value);};
loadCreds();
</script>
</body>
</html>