// identity's admin panel logic. Vanilla JS, no framework, no build step — same
// "vendored, dependency-free" choice as every other frontend in this project. See
// admin.html's top comment for why this page is the one that isn't kiosk-shaped.
"use strict";
const params = new URLSearchParams(location.search);
const API = (params.get("api") || "").replace(/\/$/, "");
const TOKEN = params.get("token") || "";
if (!API || !TOKEN) {
document.body.innerHTML =
'
identity not configured — missing ' +
"?api=&token= in the URL.
";
throw new Error("admin: missing ?api=/&token= query params");
}
// Chore types the checkboxes offer. Kept in sync BY HAND with chores/check.py's
// _CHORE_PROMPTS — there is no endpoint that lists them, because chores/ is a
// oneshot timer job with no HTTP surface at all, and inventing one just so this
// dropdown could be generated would be a lot of moving parts for a list that changes
// about once a year. "litter" is deliberately absent: it can't be assigned to anyone
// (see chores/README.md), so offering it here would be offering a lie.
const CHORE_TYPES = ["trash", "bin_full", "dishes"];
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) => {
// 409 carries a real, human-readable refusal ("that nickname collides with…"),
// so it's a result to display rather than an error to throw — same convention
// as register.js.
if (!res.ok && res.status !== 409) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`);
return body;
})
);
}
function postJson(path, body) {
return api(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
// Dates are stored as UTC ISO strings; a household admin reading "was Amir home
// Tuesday evening" wants them in their own timezone, which is what toLocaleString
// gives without this page needing to know what that timezone is.
function fmt(iso) {
if (!iso) return "—";
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
function fmtDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleDateString(undefined, { dateStyle: "medium" });
}
function duration(fromIso, toIso) {
const from = new Date(fromIso);
const to = toIso ? new Date(toIso) : new Date();
const mins = Math.round((to - from) / 60000);
if (isNaN(mins) || mins < 0) return "";
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
return hours < 24 ? `${hours}h ${mins % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
// Same blob-fetch approach as register.js/dashboard.js — every identity endpoint
// requires an Authorization header, and a plain has no way to send
// one. See identity/README.md.
function loadAvatar(container, 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);
container.replaceChildren(img);
})
.catch(() => {});
}
// --- Tabs ------------------------------------------------------------------------
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === tab));
document.querySelectorAll(".panel").forEach((p) => {
p.classList.toggle("active", p.id === `panel-${tab.dataset.panel}`);
});
if (tab.dataset.panel === "access") loadAccessLog();
});
});
// --- People list -------------------------------------------------------------------
let people = [];
function personSubtitle(p) {
const bits = [];
if (p.nickname) bits.push(`“${p.nickname}”`);
if (p.currently_home_since) bits.push(`home since ${fmt(p.currently_home_since)}`);
else bits.push(`last seen ${fmtDate(p.last_visit_at)}${p.last_visit_is_estimated ? " (never recorded)" : ""}`);
if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`);
else bits.push("no device");
if (p.device_grants.length) bits.push(`${p.device_grants.length} right${p.device_grants.length === 1 ? "" : "s"}`);
if (p.chore_exempt) bits.push("chore-exempt");
if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`);
return bits.join(" · ");
}
function loadPeople() {
const el = document.getElementById("people-list");
return api("/people")
.then((data) => {
people = data.people || [];
if (!people.length) {
el.innerHTML = '