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; }
@@ -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(""); }