k_card/k_phone/lib/portal_html.dart

232 lines
14 KiB
Dart

import 'dart:convert';
final List<int> kPortalHtmlBytes = utf8.encode(kPortalHtml);
final List<int> kEnrollHtmlBytes = utf8.encode(kEnrollHtml);
const String kPortalHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard k_phone Portal</title>
<style>
:root {
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
.hero { padding: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.card { padding: 18px; }
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
button.secondary { background: var(--accent-2); }
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard k_phone Portal</h1>
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Get Auth Token</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/auth/get-token",{});log("Auth token acquired — Component 1/3 uses this to call endpoint directly",data);}catch(err){log("Get token failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';
const String kEnrollHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard — Registration</title>
<style>
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
h1{font-size:1.25rem;font-weight:700}
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
/* user list */
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
#userList table{width:100%;border-collapse:collapse}
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
#userList tr:last-child td{border-bottom:none}
.uname{font-weight:600;font-size:.95rem}
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
.fido2{background:#d1fae5;color:#065f46}
.probe{background:#fef3c7;color:#92400e}
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
.btn-del:hover{background:var(--r);color:#fff}
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
/* form */
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
label{font-size:.8rem;color:var(--muted)}
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
input:focus{outline:2px solid var(--g);border-color:transparent}
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
#regBtn:disabled{opacity:.5;cursor:default}
/* status */
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
#msg.ok{color:#065f46}
#msg.err{color:var(--r)}
</style>
</head>
<body>
<main>
<h1>ChromeCard — User Registration</h1>
<section>
<h2>Registered users</h2>
<div id="userList"><div class="empty">Loading…</div></div>
</section>
<section>
<h2>Register new user</h2>
<form id="regForm">
<label for="uname">Username</label>
<input id="uname" placeholder="alice" autocomplete="off" required>
<label for="dname">Display name (optional)</label>
<input id="dname" placeholder="Alice Example" autocomplete="off">
<button type="submit" id="regBtn">Register — touch card fingerprint</button>
</form>
<div id="msg"></div>
</section>
</main>
<script>
var listEl=document.getElementById("userList"),
regForm=document.getElementById("regForm"),
unameEl=document.getElementById("uname"),
dnameEl=document.getElementById("dname"),
regBtn=document.getElementById("regBtn"),
msgEl=document.getElementById("msg");
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
function clearMsg(){msgEl.textContent="";msgEl.className="";}
function renderUsers(users){
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
var rows=users.map(function(u){
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
var mode=u.has_credential?'fido2':'probe';
var label=u.has_credential?'FIDO2':'probe';
return '<tr>'
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
+'</tr>';
}).join("");
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
listEl.querySelectorAll(".btn-del").forEach(function(b){
b.addEventListener("click",function(){del(b.dataset.u);});
});
}
async function loadUsers(){
try{
var r=await fetch("/enroll/list"),d=await r.json();
renderUsers(d.users||[]);
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
}
async function del(username){
if(!confirm('Delete user "'+username+'"?'))return;
clearMsg();
try{
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Delete failed");
renderUsers(d.users||[]);
setMsg('"'+username+'" deleted.',true);
}catch(e){setMsg(e.message,false);}
}
regForm.addEventListener("submit",async function(e){
e.preventDefault();clearMsg();
var username=unameEl.value.trim();
var display_name=dnameEl.value.trim()||undefined;
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
try{
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Registration failed");
renderUsers(d.users||[]);
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
unameEl.value="";dnameEl.value="";
}catch(e){setMsg(e.message,false);}
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
});
loadUsers();
</script>
</body>
</html>''';