SmartestHome/identity/frontend/register.js

218 lines
8.0 KiB
JavaScript

// 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 =
'<p class="error" style="padding:24px">identity not configured — missing ' +
"?api=&token= in the URL.</p>";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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` is undefined on a NON-SECURE ORIGIN — getUserMedia requires
// a secure context, so plain http://192.168.x.x has no camera API at all. That case is
// checked explicitly rather than left to `?.`: optional chaining short-circuits the
// WHOLE chain, so `navigator.mediaDevices?.getUserMedia(...).then(...).catch(...)`
// evaluates to undefined and neither handler ever runs — the camera silently doesn't
// start, no message appears, and registration proceeds photo-less with no explanation.
// See proxy/README.md; serving this page over HTTPS is what actually fixes it.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
cameraError.textContent = window.isSecureContext
? "No camera API in this browser (registration still works without it)."
: "Camera needs HTTPS — this page is on a non-secure origin, so the browser blocks " +
"camera access entirely. Registration still works without a photo. See proxy/README.md.";
cameraError.hidden = false;
} else {
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) =>
`<button type="button" class="card" style="width:100%;text-align:left;border:none;cursor:pointer" data-entity="${escapeHtml(c.entity_id)}">
<span class="card-name">${escapeHtml(c.friendly_name)}</span>
</button>`
)
.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 <img src="...">, because every identity endpoint
// (including this one) requires an Authorization header — an <img> 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 = '<p class="hint">Nobody registered yet.</p>';
return;
}
el.innerHTML = people
.map(
(p) =>
`<div class="card">
<span class="avatar" data-person="${p.id}">👤</span>
<span class="card-name">${escapeHtml(p.name)}</span>
<span class="card-meta">${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}</span>
</div>`
)
.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 = `<p class="error">Could not load: ${escapeHtml(err.message)}</p>`;
});
}
loadPeople();