← back to Credentials Agentabrams

public/index.html

261 lines

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Credential Actions — agentabrams</title>
<style>
  :root{
    --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a3441; --ink:#e6edf3; --dim:#9aa7b4;
    --teal:#1abc9c; --amber:#e0a44b; --red:#e5534b; --green:#3fb950; --accent:#4493f8;
    --cols:3;
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;padding:0}
  header{padding:20px 24px 12px;border-bottom:1px solid var(--line);position:sticky;top:env(safe-area-inset-top,0);background:var(--bg);z-index:5}
  h1{margin:0 0 2px;font-size:20px;letter-spacing:.3px}
  .sub{color:var(--dim);font-size:12.5px}
  .sub code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--accent)}
  .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:4}
  .controls label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-right:6px}
  select,input[type=range]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
  input[type=range]{padding:0;width:150px}
  input[type=search]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:13px;min-width:180px}
  .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
  .seg button{background:var(--panel2);color:var(--dim);border:0;padding:6px 12px;font-size:12px;cursor:pointer;border-right:1px solid var(--line)}
  .seg button:last-child{border-right:0}
  .seg button.on{background:var(--accent);color:#fff;font-weight:650}
  .stat{margin-left:auto;font-size:12.5px;color:var(--dim)}
  .stat b{color:var(--ink)}
  #densWrap{display:none}
  main{padding:20px 24px}
  .sec-hd{display:flex;align-items:center;gap:10px;margin:6px 0 12px;font-size:13px;font-weight:700;letter-spacing:.4px;text-transform:uppercase}
  .sec-hd.need{color:#ff8b83}
  .sec-hd.done{color:#6ee787;margin-top:26px;border-top:1px solid var(--line);padding-top:22px}
  .sec-hd .n{font-size:11px;background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:2px 9px;color:var(--dim)}
  .sec-empty{color:var(--dim);font-size:12.5px;font-style:italic;margin-bottom:8px}
  .vb.done{opacity:.9}
  .vb.done .li, .vb.done .card{border-left:3px solid var(--green)}

  /* ---- GRID (cards) ---- */
  .vb.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
  @media(max-width:900px){.vb.grid{grid-template-columns:1fr}}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0}
  .card.p1{border-left:3px solid var(--red)}
  .card.resolved{opacity:.62}
  .row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .title{font-size:15px;font-weight:650;letter-spacing:.2px}
  .badge{font-size:10.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;padding:3px 8px;border-radius:20px;white-space:nowrap}
  .b-out{background:rgba(229,83,75,.16);color:#ff8b83}
  .b-res{background:rgba(63,185,80,.16);color:#6ee787}
  .b-prog{background:rgba(224,164,75,.16);color:#f0b866}
  .b-pri{background:rgba(224,164,75,.16);color:#f0b866}
  .b-cls{background:rgba(68,147,248,.14);color:#79b8ff}
  .when{font-size:11px;color:var(--dim);margin-left:auto}
  .field{font-size:12.5px}
  .field .k{color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.5px;display:block;margin-bottom:2px}
  .field .v{color:var(--ink)}
  a{color:var(--accent);text-decoration:none}
  a:hover{text-decoration:underline}
  code{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:#c9d6e3;display:inline-block;max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-word}
  .why{font-size:12px;color:var(--dim);border-top:1px dashed var(--line);padding-top:8px}

  /* ---- LIST (compact single column) ---- */
  .vb.list{display:flex;flex-direction:column;gap:10px}
  .li{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line);border-radius:10px;padding:12px 14px}
  .li.p1{border-left-color:var(--red)}
  .li .hd{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
  .li .act{font-size:12.5px;color:var(--ink);margin-top:4px}
  .li .act b{color:var(--teal)}

  /* ---- TABLE ---- */
  .vb.table{display:block;overflow-x:auto}
  table{border-collapse:collapse;width:100%;font-size:12.5px}
  th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
  th{color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.5px;position:sticky;top:0;background:var(--panel)}
  tr.p1 td:first-child{border-left:3px solid var(--red)}
  td .action{color:var(--teal)}

  /* ---- KV (flat key/value) ---- */
  .vb.kv{display:flex;flex-direction:column;gap:20px}
  .kvblock{border-bottom:1px solid var(--line);padding-bottom:16px}
  .kvblock h3{margin:0 0 8px;font-size:14px}
  .kvrow{display:grid;grid-template-columns:120px 1fr;gap:10px;padding:3px 0;font-size:12.5px}
  .kvrow .kk{color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.5px;padding-top:2px}
  @media(max-width:640px){.kvrow{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
  <h1>Credential Actions <span style="color:var(--dim);font-weight:400">— agentabrams</span></h1>
  <div class="sub">Every secret Steve must personally rotate/supply · umbrella <code>TK-11786</code> · source <code>ROTATION-CHECKLIST.md</code></div>
</header>
<div class="controls">
  <div><label>View</label>
  <span class="seg" id="viewSeg">
    <button data-v="list">List</button>
    <button data-v="grid">Grid</button>
    <button data-v="table">Table</button>
    <button data-v="kv">KV</button>
  </span></div>
  <div><label>Sort</label>
  <select id="sort">
    <option value="priority">Priority</option>
    <option value="status">Status (outstanding first)</option>
    <option value="created">Newest</option>
    <option value="title">Title A→Z</option>
  </select></div>
  <div><input type="search" id="q" placeholder="Search credentials…"></div>
  <div id="densWrap"><label>Density</label><input type="range" id="dens" min="1" max="4" value="3"></div>
  <div class="stat" id="stat"></div>
</div>
<main id="grid" class="list"></main>
<script>
const $ = s => document.querySelector(s);
let ITEMS = [], VIEW = 'list';
const PRI = { P1:0, P2:1, P3:2 };
const fmtDate = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch(e){ return iso; } };
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const isDone = i => i.status==='RESOLVED' || i.status==='CONNECTED';
const statusBadge = s => (s==='RESOLVED'||s==='CONNECTED')?'b-res':(s==='IN PROGRESS'?'b-prog':'b-out');
const actionOf = i => isDone(i) ? 'connected ✓' : (i.console_steps ? 'console action' : 'route key');
const linkify = t => { t=esc(t); return t.replace(/(https?:\/\/[^\s)]+)/g,'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'); };

function sorted(){
  const sort = $('#sort').value;
  const q = ($('#q').value||'').toLowerCase().trim();
  let items = [...ITEMS];
  if(q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q));
  return items.sort((a,b)=>{
    if(sort==='priority') return (PRI[a.priority]??9)-(PRI[b.priority]??9) || a.status.localeCompare(b.status);
    if(sort==='status') return (a.status==='OUTSTANDING'?0:1)-(b.status==='OUTSTANDING'?0:1) || (PRI[a.priority]??9)-(PRI[b.priority]??9);
    if(sort==='created') return new Date(b.created_at)-new Date(a.created_at);
    return a.title.localeCompare(b.title);
  });
}

const fieldsGrid = i => [
  ['Exposed', esc(i.exposed||'—')],
  i.mint_url ? ['Mint', `<a href="${esc(i.mint_url)}" target="_blank" rel="noopener noreferrer">${esc(i.mint_url)}</a>`] : null,
  i.runbook ? ['Runbook', `<code>${esc(i.runbook)}</code>`] : null,
  ['Console steps', linkify(i.console_steps||'—')],
  ['Route back', `<code>${esc(i.route_back||'—')}</code>`],
  ['Verify', `<code>${esc(i.verify||'—')}</code>`],
].filter(Boolean);

function renderGrid(items){
  return items.map(i=>{
    const resolved = i.status==='RESOLVED';
    return `<div class="card ${i.priority==='P1'?'p1':''} ${resolved?'resolved':''}">
      <div class="row"><span class="title">${esc(i.title)}</span><span class="when" title="${esc(i.created_at)}">🕓 ${fmtDate(i.created_at)}</span></div>
      <div class="row">
        <span class="badge ${statusBadge(i.status)}">${esc(i.status)}</span>
        <span class="badge b-pri">${esc(i.priority)}</span>
        <span class="badge b-cls">${esc(i.class)}</span>
      </div>
      ${fieldsGrid(i).map(([k,v])=>`<div class="field"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('')}
      ${i.why?`<div class="why">${esc(i.why)}</div>`:''}
    </div>`;
  }).join('');
}

function renderList(items){
  return items.map(i=>`<div class="li ${i.priority==='P1'?'p1':''}">
    <div class="hd">
      <span class="title">${esc(i.title)}</span>
      <span class="badge ${statusBadge(i.status)}">${esc(i.status)}</span>
      <span class="badge b-pri">${esc(i.priority)}</span>
      <span class="when" title="${esc(i.created_at)}">🕓 ${fmtDate(i.created_at)}</span>
    </div>
    <div class="act"><b>${actionOf(i)}:</b> ${linkify(i.console_steps||i.route_back||'—')}</div>
  </div>`).join('');
}

function renderTable(items){
  const rows = items.map(i=>`<tr class="${i.priority==='P1'?'p1':''}">
    <td>${esc(i.title)}</td>
    <td><span class="badge ${statusBadge(i.status)}">${esc(i.status)}</span></td>
    <td>${esc(i.priority)}</td>
    <td>${esc(i.class)}</td>
    <td class="action">${esc(actionOf(i))}</td>
    <td>${linkify(i.console_steps||i.route_back||'—')}</td>
  </tr>`).join('');
  return `<table><thead><tr><th>Credential</th><th>Status</th><th>Pri</th><th>Class</th><th>Action</th><th>Next step</th></tr></thead><tbody>${rows}</tbody></table>`;
}

function renderKv(items){
  return items.map(i=>{
    const rows = [
      ['status', esc(i.status)], ['priority', esc(i.priority)], ['class', esc(i.class)],
      ['created', fmtDate(i.created_at)],
      ['exposed', esc(i.exposed||'—')],
      i.mint_url?['mint', `<a href="${esc(i.mint_url)}" target="_blank" rel="noopener noreferrer">${esc(i.mint_url)}</a>`]:null,
      ['console', linkify(i.console_steps||'—')],
      i.runbook?['runbook', `<code>${esc(i.runbook)}</code>`]:null,
      ['route', `<code>${esc(i.route_back||'—')}</code>`],
      ['verify', `<code>${esc(i.verify||'—')}</code>`],
      i.why?['why', esc(i.why)]:null,
    ].filter(Boolean);
    return `<div class="kvblock"><h3>${esc(i.title)}</h3>${rows.map(([k,v])=>`<div class="kvrow"><span class="kk">${k}</span><span class="vv">${v}</span></div>`).join('')}</div>`;
  }).join('');
}

function viewBody(items, groupCls){
  const inner = VIEW==='grid'?renderGrid(items):VIEW==='table'?renderTable(items):VIEW==='kv'?renderKv(items):renderList(items);
  return `<div class="vb ${VIEW} ${groupCls||''}">${inner}</div>`;
}
function render(){
  const items = sorted();
  const need = items.filter(i=>!isDone(i));
  const done = items.filter(isDone);
  $('#stat').innerHTML = `<b>${need.length}</b> needed · <b>${done.length}</b> connected · <b>${items.length}</b> shown`;
  const main = $('#grid');
  main.className = '';
  $('#densWrap').style.display = VIEW==='grid' ? '' : 'none';
  main.innerHTML =
    `<div class="sec-hd need">🔴 Action needed <span class="n">${need.length}</span></div>` +
    (need.length ? viewBody(need) : `<div class="sec-empty">Nothing outstanding — all connected 🎉</div>`) +
    `<div class="sec-hd done">🟢 Connected / done <span class="n">${done.length}</span></div>` +
    (done.length ? viewBody(done,'done') : `<div class="sec-empty">Nothing connected yet.</div>`);
  document.querySelectorAll('#viewSeg button').forEach(b=>b.classList.toggle('on', b.dataset.v===VIEW));
}

document.querySelectorAll('#viewSeg button').forEach(b=>b.onclick=()=>{ VIEW=b.dataset.v; try{localStorage.setItem('cred_view',VIEW);}catch(e){} render(); });
$('#sort').onchange = ()=>{ render(); try{localStorage.setItem('cred_sort',$('#sort').value);}catch(e){} };
$('#q').oninput = render;
$('#dens').oninput = e=>{ document.documentElement.style.setProperty('--cols', e.target.value); try{localStorage.setItem('cred_dens',e.target.value);}catch(e){} };
try{
  const v=localStorage.getItem('cred_view'); if(v)VIEW=v;
  const s=localStorage.getItem('cred_sort'); if(s)$('#sort').value=s;
  const d=localStorage.getItem('cred_dens'); if(d){$('#dens').value=d;document.documentElement.style.setProperty('--cols',d);}
}catch(e){}

fetch('/api/credentials').then(r=>r.json()).then(d=>{ ITEMS=d.items||[]; render(); }).catch(e=>{ $('#grid').innerHTML='<p style="color:var(--red)">Failed to load: '+e+'</p>'; });
</script>
</body>
</html>