// identity's registration page logic. Vanilla JS, no framework — see // register.html's top comment for why this is the fallback path, not the primary // (voice) one. "use strict"; const params = new URLSearchParams(location.search); const API = (params.get("api") || "").replace(/\/$/, ""); const TOKEN = params.get("token") || ""; const DEVICE_ID = params.get("device") || "unknown"; if (!API || !TOKEN) { document.body.innerHTML = '

identity not configured — missing ' + "?api=&token= in the URL.

"; throw new Error("identity: missing ?api=/&token= query params"); } function api(path, options) { options = options || {}; options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); return fetch(`${API}${path}`, options).then((res) => res.json().then((body) => { if (!res.ok && res.status !== 409) throw new Error(body.error || `${res.status} ${res.statusText}`); return body; }) ); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // --- Camera (best-effort — registration still works with a text name + voice-found // candidate even if the camera fails; the photo is an audit artifact, never load- // bearing for the actual identity decision, see ../README.md) ----------------- const video = document.getElementById("camera-preview"); const cameraError = document.getElementById("camera-error"); let stream = null; navigator.mediaDevices ?.getUserMedia({ video: { facingMode: "user" }, audio: false }) .then((s) => { stream = s; video.srcObject = s; }) .catch((err) => { cameraError.textContent = `Camera unavailable: ${err.message} (registration still works without it)`; cameraError.hidden = false; }); function capturePhoto() { if (!stream) return Promise.resolve(null); const canvas = document.getElementById("captured-frame"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext("2d").drawImage(video, 0, 0); return new Promise((resolve) => { canvas.toBlob( (blob) => { if (!blob) return resolve(null); api("/register/photo", { method: "POST", body: blob, headers: { "Content-Type": "image/jpeg" } }) .then((r) => resolve(r.photo_id || null)) .catch(() => resolve(null)); }, "image/jpeg", 0.85 ); }); } // --- Registration ------------------------------------------------------------ const form = document.getElementById("register-form"); const status = document.getElementById("register-status"); const picker = document.getElementById("candidate-picker"); const candidateList = document.getElementById("candidate-list"); function attemptRegister(name, entityId, noDevice) { status.textContent = "Registering…"; picker.hidden = true; return capturePhoto().then((photoId) => api("/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, device_id: DEVICE_ID, photo_id: photoId, entity_id: entityId, no_device: !!noDevice, }), }) ).then((result) => { if (result.ok) { status.textContent = result.message; form.reset(); loadPeople(); return; } if (result.reason === "ambiguous") { status.textContent = result.message; candidateList.innerHTML = result.candidates .map( (c) => `` ) .join(""); picker.hidden = false; candidateList.querySelectorAll("[data-entity]").forEach((btn) => { btn.addEventListener("click", () => attemptRegister(name, btn.dataset.entity, false)); }); return; } status.textContent = result.message || "Could not register."; }).catch((err) => { status.textContent = `Error: ${err.message}`; }); } form.addEventListener("submit", (event) => { event.preventDefault(); const name = document.getElementById("f-name").value.trim(); const noDevice = document.getElementById("f-no-device").checked; if (name) attemptRegister(name, null, noDevice); }); // --- Guest (no name, no device — see identity/README.md) ----------------------- const guestBtn = document.getElementById("guest-btn"); const guestStatus = document.getElementById("guest-status"); guestBtn.addEventListener("click", () => { guestStatus.textContent = "Adding guest…"; capturePhoto() .then((photoId) => api("/register/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_id: DEVICE_ID, photo_id: photoId }), }) ) .then((result) => { guestStatus.textContent = result.message; loadPeople(); }) .catch((err) => { guestStatus.textContent = `Error: ${err.message}`; }); }); // --- Profile pictures ---------------------------------------------------------- // Fetched via blob, not a plain , because every identity endpoint // (including this one) requires an Authorization header — an tag has no way // to send one, so a bare src= URL would just 401. See identity/README.md for why // every registration also updates the person's profile photo to whatever the // kiosk's camera last captured. function loadAvatar(imgContainer, personId) { fetch(`${API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${TOKEN}` } }) .then((res) => (res.ok ? res.blob() : Promise.reject())) .then((blob) => { const img = document.createElement("img"); img.src = URL.createObjectURL(blob); imgContainer.replaceChildren(img); }) .catch(() => { /* no photo — leave the plain circle placeholder */ }); } // --- Already-registered list --------------------------------------------------- function loadPeople() { const el = document.getElementById("people-list"); api("/people") .then((data) => { const people = data.people || []; if (!people.length) { el.innerHTML = '

Nobody registered yet.

'; return; } el.innerHTML = people .map( (p) => `
👤 ${escapeHtml(p.name)} ${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}
` ) .join(""); people.forEach((p) => { if (p.has_photo) { const el2 = el.querySelector(`.avatar[data-person="${p.id}"]`); if (el2) loadAvatar(el2, p.id); } }); }) .catch((err) => { el.innerHTML = `

Could not load: ${escapeHtml(err.message)}

`; }); } loadPeople();