diff --git a/docs/device-auth-contract.md b/docs/device-auth-contract.md index 435f1c6..49f2e75 100644 --- a/docs/device-auth-contract.md +++ b/docs/device-auth-contract.md @@ -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 diff --git a/pb_hooks/device_page.pb.js b/pb_hooks/device_page.pb.js index fc3cb3c..1675b43 100644 --- a/pb_hooks/device_page.pb.js +++ b/pb_hooks/device_page.pb.js @@ -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; } @@ -77,6 +80,14 @@ routerAdd("GET", "/device", (e) => { + +
@@ -139,6 +150,10 @@ function b64d(s){var bin=atob(s),u8=new Uint8Array(bin.length);for(var i=0;i4?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í: " + (info.deviceName ? escapeHtml(info.deviceName) : "nové zařízení") + ""; 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 {"&":"&","<":"<",">":">",'"':""","'":"'"}[c];}); } @@ -273,7 +303,33 @@ async function doDecline(){ approveBtn.addEventListener("click", doApprove); declineBtn.addEventListener("click", doDecline); -loadInfo(); + +// 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(""); } `