// 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"];
// digest-engine's four sections, in the order they're offered. The keys are its own
// section ids (synth/prompts/.md), which is also exactly what the server stores —
// nothing translates between the two. The labels are the household's words for them:
// "personal" is the mail/messages one, "political" the news one. Kept in sync BY HAND
// with DIGEST_SECTIONS in server.py, same arrangement as CHORE_TYPES above, except the
// server does validate these — an unknown key comes back as a refusal, not a silent
// write.
const DIGEST_SECTIONS = [
["network", "Network"],
["household", "Household"],
["personal", "Social"],
["political", "Political / news"],
];
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.
// --- Colour + profile picture --------------------------------------------------------
// The palette comes from the server (GET /person-colors) rather than being repeated
// here: those eight values are chosen to render exactly on a colour Pebble's 2-bit-per-
// channel screen, and a second copy in this file would drift the first time somebody
// "improved" one of them. Falls back to whatever the person already has if the fetch
// fails, so the editor still opens.
let personColors = [];
function loadPersonColors() {
return api("/person-colors")
.then((data) => {
personColors = data.colors || [];
})
.catch(() => {
personColors = [];
});
}
function renderColorSwatches() {
const row = document.getElementById("edit-colors");
const current = (editing.color || "").toUpperCase();
const palette = personColors.length ? personColors : [current].filter(Boolean);
// A colour set by hand outside the palette still gets a swatch, so it is visible and
// reselectable rather than silently absent from its own editor.
const colors = palette.includes(current) || !current ? palette : palette.concat([current]);
row.innerHTML = colors
.map(
(color) =>
``
)
.join("");
row.querySelectorAll(".swatch").forEach((btn) =>
btn.addEventListener("click", () => {
editing.color = btn.dataset.color;
renderColorSwatches();
const avatar = document.getElementById("edit-avatar");
if (!editing.has_photo) avatar.style.background = editing.color;
})
);
}
document.getElementById("edit-photo-upload").addEventListener("click", () => {
document.getElementById("edit-photo-file").click();
});
document.getElementById("edit-photo-file").addEventListener("change", (event) => {
const file = event.target.files && event.target.files[0];
if (!file || !editing) return;
setStatus("Uploading…");
// Raw bytes, not multipart — same shape as the level-image upload and
// /register/photo. The picture is written immediately rather than waiting for Save,
// because it is a file on the server, not a field in this form.
api(`/people/${editing.id}/photo`, { method: "POST", body: file, headers: { "Content-Type": file.type || "image/jpeg" } })
.then((result) => {
if (!result.ok) throw new Error(result.message || "Could not upload.");
return refreshEditing("Picture updated.");
})
.catch((err) => setStatus(err.message, true))
.finally(() => {
event.target.value = "";
});
});
document.getElementById("edit-photo-clear").addEventListener("click", () => {
if (!editing) return;
setStatus("Removing…");
postJson(`/people/${editing.id}`, { clear_photo: true })
.then((result) => {
if (!result.ok) throw new Error(result.message || "Could not remove.");
return refreshEditing("Picture removed — showing their initial instead.");
})
.catch((err) => setStatus(err.message, true));
});
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();
if (tab.dataset.panel === "floorplan") {
// Loaded on open rather than at startup: it costs an HA round trip for the area
// suggestions, and most visits to this page never touch the floorplan.
loadFloorplan().then(loadFloorplanPresence);
loadAreaSuggestions();
}
});
});
// --- 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.notify_on_arrival) bits.push(p.notify_deliverable ? "🔔 arrivals" : "🔔 arrivals (undeliverable)");
if (!p.announce_arrivals) bits.push("not announced");
// Only worth a line when it isn't the default (everything) — otherwise every person
// would carry the same four words.
if (p.digest_sections.length < DIGEST_SECTIONS.length) {
bits.push(p.digest_sections.length ? `digests: ${p.digest_sections.join(", ")}` : "no digests");
}
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 = '
`;
})
.join("");
el.querySelectorAll("[data-drop-grant]").forEach((btn) => {
btn.addEventListener("click", () => {
api(`/people/${editing.id}/device-grants/${btn.dataset.dropGrant}`, { method: "DELETE" })
.then(() => refreshEditing("Right revoked."))
.catch((err) => setStatus(err.message, true));
});
});
}
// Re-reads /people and re-points the open dialog at the fresh record, so a sub-action
// (revoking a device, adding a grant) doesn't leave the dialog showing stale data or
// force the admin to close and reopen it.
function refreshEditing(message) {
const id = editing.id;
return loadPeople().then(() => {
editing = people.find((p) => p.id === id);
if (!editing) {
editor.close();
return;
}
renderIdentifiers();
renderGrants();
if (message) setStatus(message);
});
}
document.getElementById("edit-close").addEventListener("click", () => editor.close());
document.getElementById("edit-save").addEventListener("click", () => {
const chores = Array.from(document.querySelectorAll("[data-chore]:checked")).map((c) => c.dataset.chore);
setStatus("Saving…");
// Two calls because they're two different resources, not one form: the person's own
// fields, and their chore assignments. Sequenced rather than parallel so a rejected
// rename (a nickname collision, say) surfaces its message instead of racing the
// assignment call's own status text.
postJson(`/people/${editing.id}`, {
name: document.getElementById("edit-name").value.trim(),
nickname: document.getElementById("edit-nickname").value.trim(),
note: document.getElementById("edit-note").value.trim(),
chore_exempt: document.getElementById("edit-chore-exempt").checked,
chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(),
notify_on_arrival: document.getElementById("edit-notify-on-arrival").checked,
announce_arrivals: document.getElementById("edit-announce-arrivals").checked,
notify_topic: document.getElementById("edit-notify-topic").value.trim(),
// Whatever swatch is currently selected — the picker mutates `editing.color` and
// saves with the rest of the dialog, since it is one more column on `people`.
color: editing.color || "",
// Sent with the rest of the person's own fields rather than as a third call: unlike
// chore assignments (their own table), this is one column on `people`, so it saves
// or is refused together with everything else in the dialog.
digest_sections: Array.from(document.querySelectorAll("[data-digest]:checked")).map(
(c) => c.dataset.digest
),
})
.then((result) => {
if (!result.ok) throw new Error(result.message || "Could not save.");
return postJson(`/people/${editing.id}/chore-assignments`, { chore_types: chores });
})
.then(() => refreshEditing("Saved."))
.then(() => {
document.getElementById("edit-title").textContent = editing ? editing.name : "";
})
.catch((err) => setStatus(err.message, true));
});
// Deliberately sends to whatever is SAVED, not what's typed in the box — a test that
// silently used unsaved input would prove a topic works and then leave a different one
// stored. Save first, then test.
document.getElementById("edit-test-notify").addEventListener("click", () => {
setStatus("Sending test…");
postJson(`/people/${editing.id}/test-notification`, {})
.then((result) => setStatus(result.message, !result.ok))
.catch((err) => setStatus(err.message, true));
});
document.getElementById("edit-add-identifier").addEventListener("click", () => {
const input = document.getElementById("edit-new-identifier");
const entityId = input.value.trim();
if (!entityId) return;
postJson(`/people/${editing.id}/identifiers`, { entity_id: entityId })
.then((result) => {
if (!result.ok) throw new Error(result.message);
input.value = "";
return refreshEditing("Device added.");
})
.catch((err) => setStatus(err.message, true));
});
document.getElementById("grant-add").addEventListener("click", () => {
const entity = document.getElementById("grant-entity");
const permission = document.getElementById("grant-permission");
const expires = document.getElementById("grant-expires");
if (!entity.value.trim()) return;
postJson(`/people/${editing.id}/device-grants`, {
entity_id: entity.value.trim(),
permission: permission.value.trim() || "operate",
// A date input gives "2026-08-01"; the grant expires at the START of that day, so
// "until 1 Aug" means the last usable day is 31 Jul. Sent explicitly as UTC
// midnight rather than left for the server to interpret a bare date.
expires_at: expires.value ? `${expires.value}T00:00:00Z` : null,
})
.then((result) => {
if (!result.ok) throw new Error(result.message);
entity.value = "";
permission.value = "";
expires.value = "";
return refreshEditing("Right granted.");
})
.catch((err) => setStatus(err.message, true));
});
document.getElementById("edit-delete").addEventListener("click", () => {
if (!confirm(`Delete ${editing.name} completely? Their devices, rights and visit history go too.`)) return;
api(`/people/${editing.id}`, { method: "DELETE" })
.then(() => {
editor.close();
loadPeople();
})
.catch((err) => setStatus(err.message, true));
});
// --- Prune ---------------------------------------------------------------------------
// The two-step shape here is deliberate and matches the backend: /prune/candidates
// SELECTS, POST /people/prune DELETES the exact ids that came back and stayed ticked.
// The filter never gets re-run at delete time, so someone who walks in the door
// between "Select all" and "Delete selected" can't be swept up by a filter that
// silently re-evaluated. See prune_people()'s docstring in server.py.
const pruneList = document.getElementById("prune-list");
const pruneDelete = document.getElementById("prune-delete");
const pruneStatus = document.getElementById("prune-status");
function selectedPruneIds() {
return Array.from(pruneList.querySelectorAll("input[type=checkbox]:checked")).map((c) => Number(c.value));
}
function syncPruneButton() {
const n = selectedPruneIds().length;
pruneDelete.disabled = n === 0;
pruneDelete.textContent = n ? `Delete ${n} selected` : "Delete selected";
}
document.getElementById("prune-select").addEventListener("click", () => {
const date = document.getElementById("prune-date").value;
if (!date) {
document.getElementById("prune-summary").textContent = "Pick a date first.";
return;
}
pruneStatus.textContent = "";
api(`/prune/candidates?last_visit_before=${encodeURIComponent(date)}`)
.then((data) => {
const summary = document.getElementById("prune-summary");
if (!data.count) {
summary.textContent = `Nobody has a last visit before ${fmtDate(date)}.`;
pruneList.innerHTML = "";
syncPruneButton();
return;
}
summary.textContent = `${data.count} match${data.count === 1 ? "" : "es"} — untick anyone you want to keep.`;
pruneList.innerHTML = data.candidates
.map(
(p) =>
``
)
.join("");
pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton));
syncPruneButton();
})
.catch((err) => {
document.getElementById("prune-summary").innerHTML = `${escapeHtml(err.message)}`;
});
});
pruneDelete.addEventListener("click", () => {
const ids = selectedPruneIds();
if (!ids.length) return;
const names = ids.map((id) => (people.find((p) => p.id === id) || {}).name || id);
if (!confirm(`Delete ${ids.length} record(s)?\n\n${names.join("\n")}\n\nThis cannot be undone.`)) return;
pruneStatus.textContent = "Deleting…";
postJson("/people/prune", { person_ids: ids })
.then((result) => {
pruneStatus.textContent = `Deleted ${result.deleted.length} record(s).`;
pruneList.innerHTML = "";
document.getElementById("prune-summary").textContent = "";
syncPruneButton();
loadPeople();
})
.catch((err) => {
pruneStatus.innerHTML = `${escapeHtml(err.message)}`;
});
});
// --- History --------------------------------------------------------------------------
function populatePersonSelect() {
const select = document.getElementById("history-person");
const current = select.value;
select.innerHTML =
'' +
people.map((p) => ``).join("");
select.value = current;
}
function loadHistory() {
const personId = document.getElementById("history-person").value;
const sinceDate = document.getElementById("history-since").value;
const since = sinceDate ? `${sinceDate}T00:00:00Z` : "";
const query = since ? `?since=${encodeURIComponent(since)}` : "";
const visitPath = personId ? `/people/${personId}/visits${query}` : `/visits${query}`;
const el = document.getElementById("visit-list");
el.innerHTML = '