Skip to content
Merged
57 changes: 44 additions & 13 deletions scanner/dashboard/console.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
<title>AppGuardrail Console</title>
<!--
Org console for the AppGuardrail control plane (`appguardrail serve`).
Paste an org API key, then it calls GET /api/v1/scans (same origin) to show
scan history, the deploy-blocking trend, and per-scan detail.
Paste a viewer-scoped org API key, then it calls GET /api/v1/scans (same
origin) to show scan history, the deploy-blocking trend, and per-scan detail.
The browser keeps the key in memory only; owner operations are not exposed.
ponytail: no framework, no build step. Consumes the API in controlplane.py.
-->
<style>
Expand All @@ -25,8 +26,7 @@
main{max-width:1000px;margin:0 auto;padding:20px}
input{font:inherit;padding:8px 10px;border:1px solid var(--border);border-radius:8px;min-width:280px}
button{font:inherit;font-weight:600;padding:8px 14px;border:0;border-radius:8px;background:var(--primary);color:var(--on-primary);cursor:pointer;transition:filter 0.2s, opacity 0.2s}
button:hover:not(:disabled){filter:brightness(0.9)}
button:disabled{opacity:0.6;cursor:not-allowed}
button:hover:not(:disabled){filter:brightness(.94)}
button.ghost{background:var(--surface);color:var(--primary);border:1px solid var(--border)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:16px}
.stats{display:flex;gap:12px;flex-wrap:wrap}
Expand All @@ -38,8 +38,9 @@
th{font-size:11px;color:var(--muted);text-transform:uppercase}
tr.scan{cursor:pointer}
tr.scan:hover{background:var(--bg)}
tr.scan[aria-busy="true"]{opacity:0.7;pointer-events:none}
input:focus-visible, button:focus-visible, tr.scan:focus-visible, .bar:focus-visible, #detail:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
button:disabled, button[aria-busy="true"] { opacity: 0.6; cursor: not-allowed; }
tr.scan[aria-busy="true"] { opacity: 0.7; cursor: wait; pointer-events: none; }
.close-btn{float:right;border:0;background:transparent;font-size:16px;cursor:pointer;color:var(--muted);padding:0 4px;margin-top:-2px}
.close-btn:hover{color:var(--text)}
.pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;color:#fff}
Expand All @@ -57,12 +58,12 @@
<h1>AppGuardrail Console</h1>
<span class="muted" id="conn" role="status" aria-live="polite" aria-atomic="true"></span>
<span style="flex:1"></span>
<input id="key" type="password" placeholder="Org API key (agk_…)" autocomplete="off" aria-label="Organization API key">
<input id="key" type="password" placeholder="Viewer API key (agk_…)" autocomplete="off" aria-label="Viewer-scoped organization API key">
<button id="connect">Connect</button>
<button id="logout" class="ghost hidden">Sign out</button>
</header>
<main>
<div id="msg" class="card muted" role="alert" aria-live="polite">Paste your org API key and connect to view scan history.</div>
<div id="msg" class="card muted" role="alert" aria-live="polite">Paste a viewer-scoped organization API key. This console keeps it only in this tab's memory and never stores it.</div>
<div id="app" class="hidden">
<div class="stats" id="stats"></div>
<div class="card">
Expand All @@ -80,7 +81,8 @@ <h1>AppGuardrail Console</h1>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const SEV={CRITICAL:"var(--crit)",HIGH:"var(--high)",WARNING:"var(--warn)",INFO:"var(--info)"};
let KEY=sessionStorage.getItem("ag_key")||"";
let KEY="";
let connecting=false;
let currentDetailRequest=0;
let lastDetailFocus=null;

Expand Down Expand Up @@ -141,8 +143,9 @@ <h1>AppGuardrail Console</h1>
tr.onclick=()=>detail(tr.dataset.id,tr);
tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); detail(tr.dataset.id,tr); } });
});
return true;
}catch(e){ $("#msg").classList.remove("hidden");$("#app").classList.add("hidden");
$("#msg").innerHTML=`<span class="err">${esc(e.message)}</span>`; }
$("#msg").innerHTML=`<span class="err">${esc(e.message)}</span>`;return false; }
}
async function detail(id,tr){
const requestId=++currentDetailRequest;
Expand Down Expand Up @@ -180,10 +183,38 @@ <h1>AppGuardrail Console</h1>
}
}
}
$("#connect").onclick=async()=>{KEY=$("#key").value.trim();if(!KEY)return;sessionStorage.setItem("ag_key",KEY);$("#key").value="";$("#connect").disabled=true;$("#connect").setAttribute("aria-busy","true");$("#connect").textContent="Connecting...";await load();$("#connect").disabled=false;$("#connect").removeAttribute("aria-busy");$("#connect").textContent="Connect";};
$("#key").addEventListener("keydown",e=>{if(e.key==="Enter")$("#connect").click();});
$("#logout").onclick=()=>{sessionStorage.removeItem("ag_key");location.reload();};
if(KEY)load();
function syncConnectState(){
const button=$("#connect");
button.disabled=connecting||!$("#key").value.trim();
if(connecting){
button.setAttribute("aria-busy","true");
button.textContent="Connecting...";
}else{
button.removeAttribute("aria-busy");
button.textContent="Connect";
}
}
async function connect(){
if(connecting)return;
const candidate=$("#key").value.trim();
if(!candidate)return;
KEY=candidate;
$("#key").value="";
connecting=true;
syncConnectState();
try{
const connected=await load();
if(!connected)KEY="";
}finally{
connecting=false;
syncConnectState();
}
}
$("#connect").onclick=connect;
$("#key").addEventListener("input",syncConnectState);
$("#key").addEventListener("keydown",e=>{if(e.key==="Enter"&&!connecting)$("#connect").click();});
$("#logout").onclick=()=>{KEY="";location.reload();};
syncConnectState();
</script>
</body>
</html>
54 changes: 54 additions & 0 deletions tests/test_console_detail_loading_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,57 @@ def test_console_detail_scrolling_respects_reduced_motion():
assert "element.scrollIntoView();" in html
assert 'element.scrollIntoView({behavior:"smooth"});' in html
assert html.count("scrollDetailIntoView(d);") == 2


def test_console_keeps_org_api_key_ephemeral_and_requests_viewer_scope():
"""The read-only console must not persist an owner-capable bearer key."""
html = _console_html()

assert "sessionStorage" not in html
assert 'placeholder="Viewer API key (agk_…)"' in html
assert "viewer-scoped" in html
assert 'let KEY="";' in html
assert '$("#logout").onclick=()=>{KEY="";location.reload();};' in html


def test_console_connection_state_is_exception_safe_and_single_flight():
"""Connection cleanup and input edits must not permit overlapping loads."""
html = _console_html()

assert "let connecting=false;" in html
assert "function syncConnectState(){" in html
assert 'button.disabled=connecting||!$("#key").value.trim();' in html
assert 'button.setAttribute("aria-busy","true");' in html
assert 'button.removeAttribute("aria-busy");' in html
assert "async function connect(){" in html
assert "if(connecting)return;" in html
assert "try{" in html
assert "const connected=await load();" in html
assert 'if(!connected)KEY="";' in html
assert "}finally{" in html
assert '$("#key").addEventListener("input",syncConnectState);' in html
assert "if(KEY)load()" not in html
assert "syncConnectState();" in html


def test_console_escapes_scan_identifiers_in_attribute_context():
"""Untrusted scan identifiers must not break out of the data-id attribute."""
html = _console_html()

assert 'data-id="${esc(s.id)}"' in html
assert 'data-id="${s.id}"' not in html


def test_console_close_buttons_explain_the_escape_shortcut():
"""Both success and error close controls must expose their keyboard shortcut."""
html = _console_html()

assert html.count('aria-label="Close details" title="Close (Esc)"') == 2


def test_console_hover_feedback_excludes_disabled_controls():
"""Hover feedback must not imply that a disabled control can be activated."""
html = _console_html()

assert "button:hover:not(:disabled)" in html
assert "transition:filter 0.2s, opacity 0.2s" in html