Device approve page: manual code-entry for the QR-less path #5

Merged
richiexec merged 1 commit from fix/device-approve-code-entry into main 2026-07-20 09:08:52 +00:00
2 changed files with 66 additions and 6 deletions
Showing only changes of commit 15be3bd5e5 - Show all commits

View file

@ -74,7 +74,11 @@ Body `{ code }`. Sets `status=denied` and scrubs the pending secrets. → `{ ok
- Revoke: `DELETE /api/collections/device_auth/records/{id}`
### `GET /device?code=CODE` (HTML)
The self-contained approve page (`device_page.pb.js`). No external assets.
The self-contained approve page (`device_page.pb.js`). No external assets. The
`?code=` (QR / direct link) is optional: opened bare, the page shows a manual
code-entry field so the user can type the `XXXX-XXXX` code shown on the TV. The
page normalizes (uppercase, strips the dash) before the `/info` + `/approve`
lookups, since the code is stored dash-less.
### Cleanup cron (`*/5 * * * *`)
Scrubs `authToken`/`keyCiphertext`/`pollSecret`/`devicePubKey` from approved rows

View file

@ -68,6 +68,9 @@ routerAdd("GET", "/device", (e) => {
@keyframes s { to { transform:rotate(360deg); } }
.hidden { display:none; }
.note { margin-top:16px; font-size:12px; color:var(--muted); line-height:1.5; }
.code-input { text-transform:uppercase; letter-spacing:.22em; text-align:center;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:20px;
font-weight:600; }
</style>
</head>
<body>
@ -77,6 +80,14 @@ routerAdd("GET", "/device", (e) => {
<div id="devbox" class="dev hidden"></div>
<div id="codeform" class="hidden">
<label for="codeInput">Kód ze zařízení</label>
<input id="codeInput" class="code-input" type="text" inputmode="text"
autocomplete="off" autocapitalize="characters" spellcheck="false"
maxlength="9" placeholder="XXXX-XXXX" />
<button id="codeGo" class="primary">Pokračovat</button>
</div>
<div id="form">
<label for="email">E-mail</label>
<input id="email" type="email" autocomplete="username" inputmode="email" />
@ -139,6 +150,10 @@ function b64d(s){var bin=atob(s),u8=new Uint8Array(bin.length);for(var i=0;i<bin
function concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
function qs(name){return new URLSearchParams(location.search).get(name)||"";}
// The human code is 8 chars shown as XXXX-XXXX but stored dash-less; normalize
// (uppercase, drop the dash/spaces, cap at 8) before any /info + /approve lookup.
function normCode(s){return String(s||"").toUpperCase().replace(/[^0-9A-Z]/g,"").slice(0,8);}
function fmtCode(s){var c=normCode(s);return c.length>4?c.slice(0,4)+"-"+c.slice(4):c;}
async function deriveVaultKeyB64(password, saltB64){
var salt=b64d(saltB64);
@ -163,7 +178,7 @@ async function sealTo(tvPubB64, plaintext){
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
// ── page state ───────────────────────────────────────────────────────────────
var CODE = qs("code").toUpperCase().trim();
var CODE = normCode(qs("code"));
var info = null;
var msgEl = document.getElementById("msg");
var approveBtn = document.getElementById("approve");
@ -184,12 +199,21 @@ async function api(method, path, body, token){
return { ok:r.ok, status:r.status, data:data };
}
function showCodeEntry(prefill){
document.getElementById("form").classList.add("hidden");
document.getElementById("devbox").classList.add("hidden");
document.getElementById("codeform").classList.remove("hidden");
var input = document.getElementById("codeInput");
if (prefill) input.value = fmtCode(prefill);
input.focus();
}
async function loadInfo(){
if (!CODE){ setMsg("Chybí kód zařízení v odkazu.", "err"); document.getElementById("form").classList.add("hidden"); return; }
if (!CODE){ showCodeEntry(""); setMsg("Zadejte kód zobrazený na televizi.", ""); return; }
var r = await api("GET", "/api/device-auth/info?code=" + encodeURIComponent(CODE));
if (!r.ok){
setMsg("Požadavek nebyl nalezen nebo vypršel. Vytvořte na televizi nový.", "err");
document.getElementById("form").classList.add("hidden");
showCodeEntry(CODE);
setMsg("Kód nebyl nalezen nebo vypršel. Zkontrolujte ho, nebo vytvořte na televizi nový.", "err");
return;
}
info = r.data;
@ -198,8 +222,14 @@ async function loadInfo(){
box.innerHTML = "Přihlásit zařízení: <b>" + (info.deviceName ? escapeHtml(info.deviceName) : "nové zařízení") + "</b>";
if (info.status && info.status !== "pending"){
setMsg("Tento požadavek už byl vyřízen.", "err");
document.getElementById("codeform").classList.add("hidden");
document.getElementById("form").classList.add("hidden");
return;
}
// code accepted → reveal the sign-in form
document.getElementById("codeform").classList.add("hidden");
document.getElementById("form").classList.remove("hidden");
setMsg("");
}
function escapeHtml(s){ return String(s).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];}); }
@ -273,7 +303,33 @@ async function doDecline(){
approveBtn.addEventListener("click", doApprove);
declineBtn.addEventListener("click", doDecline);
// Manual code entry: when the page is opened bare (no ?code= — e.g. the TV tells
// the user to open …/device and type the XXXX-XXXX code). The QR/direct-link path
// still fills CODE from the URL and skips straight to sign-in.
var codeInput = document.getElementById("codeInput");
var codeGo = document.getElementById("codeGo");
function submitCode(){
CODE = normCode(codeInput.value);
if (CODE.length !== 8){ setMsg("Zadejte celý kód z televize (např. 25K4-CFQP).", "err"); return; }
setMsg("");
loadInfo();
}
codeInput.addEventListener("input", function(){
var before = codeInput.value.length;
codeInput.value = fmtCode(codeInput.value);
// when the auto-dash shifts length, park the caret at the end (8-char field)
if (codeInput.value.length !== before){
codeInput.setSelectionRange(codeInput.value.length, codeInput.value.length);
}
});
codeInput.addEventListener("keydown", function(ev){ if (ev.key === "Enter"){ ev.preventDefault(); submitCode(); } });
codeGo.addEventListener("click", submitCode);
// Boot: URL code → sign-in; otherwise show the manual code-entry field.
if (CODE){ loadInfo(); } else { showCodeEntry(""); }
</script>
</body>
</html>`