203 lines
7.0 KiB
JavaScript
203 lines
7.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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) =>
|
|
`<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();
|