549 lines
23 KiB
JavaScript
549 lines
23 KiB
JavaScript
// 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 =
|
|
'<p class="error" style="padding:24px">identity not configured — missing ' +
|
|
"?api=&token= in the URL.</p>";
|
|
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 <img src="..."> 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.notify_on_arrival) bits.push(p.notify_deliverable ? "🔔 arrivals" : "🔔 arrivals (undeliverable)");
|
|
if (!p.announce_arrivals) bits.push("not announced");
|
|
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 = '<p class="hint">Nobody registered yet.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = people
|
|
.map(
|
|
(p) =>
|
|
`<button type="button" class="card as-button" data-person="${p.id}">
|
|
<span class="avatar" data-avatar="${p.id}">👤</span>
|
|
<span class="card-body">
|
|
<span class="card-name">${escapeHtml(p.name)}${p.currently_home_since ? ' <span class="dot-home" title="home now"></span>' : ""}</span>
|
|
<span class="card-meta">${escapeHtml(personSubtitle(p))}</span>
|
|
</span>
|
|
</button>`
|
|
)
|
|
.join("");
|
|
el.querySelectorAll("[data-person]").forEach((btn) => {
|
|
btn.addEventListener("click", () => openEditor(Number(btn.dataset.person)));
|
|
});
|
|
people.forEach((p) => {
|
|
if (p.has_photo) {
|
|
const avatar = el.querySelector(`[data-avatar="${p.id}"]`);
|
|
if (avatar) loadAvatar(avatar, p.id);
|
|
}
|
|
});
|
|
populatePersonSelect();
|
|
})
|
|
.catch((err) => {
|
|
el.innerHTML = `<p class="error">Could not load people: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
// --- Person editor ------------------------------------------------------------------
|
|
const editor = document.getElementById("editor");
|
|
const editStatus = document.getElementById("edit-status");
|
|
let editing = null;
|
|
|
|
function setStatus(text, isError) {
|
|
editStatus.textContent = text;
|
|
editStatus.className = isError ? "error" : "hint";
|
|
}
|
|
|
|
function openEditor(personId) {
|
|
editing = people.find((p) => p.id === personId);
|
|
if (!editing) return;
|
|
|
|
document.getElementById("edit-title").textContent = editing.name;
|
|
document.getElementById("edit-name").value = editing.name;
|
|
document.getElementById("edit-nickname").value = editing.nickname || "";
|
|
document.getElementById("edit-note").value = editing.note || "";
|
|
document.getElementById("edit-chore-exempt").checked = editing.chore_exempt;
|
|
document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || "";
|
|
document.getElementById("edit-notify-on-arrival").checked = editing.notify_on_arrival;
|
|
document.getElementById("edit-announce-arrivals").checked = editing.announce_arrivals;
|
|
document.getElementById("edit-notify-topic").value = editing.notify_topic || "";
|
|
// Says out loud when a ticked box still can't deliver, rather than leaving someone
|
|
// to cross-reference a checkbox against an env file to work out why nothing arrives.
|
|
document.getElementById("notify-hint").innerHTML = editing.notify_deliverable
|
|
? "Pushes go out via the household ntfy server."
|
|
: '<span class="error">No ntfy topic reachable — set NTFY_URL and NTFY_DEFAULT_TOPIC on the server, or a topic here.</span>';
|
|
|
|
const avatar = document.getElementById("edit-avatar");
|
|
avatar.replaceChildren(document.createTextNode("👤"));
|
|
if (editing.has_photo) loadAvatar(avatar, editing.id);
|
|
|
|
document.getElementById("edit-chore-types").innerHTML = CHORE_TYPES.map(
|
|
(type) =>
|
|
`<label class="chip"><input type="checkbox" data-chore="${type}"${
|
|
editing.chore_assignments.includes(type) ? " checked" : ""
|
|
}> ${escapeHtml(type)}</label>`
|
|
).join("");
|
|
|
|
renderIdentifiers();
|
|
renderGrants();
|
|
setStatus("");
|
|
editor.showModal();
|
|
}
|
|
|
|
function renderIdentifiers() {
|
|
const el = document.getElementById("edit-identifiers");
|
|
if (!editing.identifiers.length) {
|
|
el.innerHTML = '<p class="hint">No devices — presence has to be set by hand.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = editing.identifiers
|
|
.map(
|
|
(i) =>
|
|
`<div class="card compact">
|
|
<span class="card-body">
|
|
<span class="card-name mono">${escapeHtml(i.ha_entity_id)}</span>
|
|
<span class="card-meta">added ${escapeHtml(fmtDate(i.registered_at))} via ${escapeHtml(i.registered_via_device || "?")}</span>
|
|
</span>
|
|
<button type="button" class="btn danger small" data-drop-identifier="${i.id}">Revoke</button>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
el.querySelectorAll("[data-drop-identifier]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
api(`/people/${editing.id}/identifiers/${btn.dataset.dropIdentifier}`, { method: "DELETE" })
|
|
.then(() => refreshEditing("Device revoked."))
|
|
.catch((err) => setStatus(err.message, true));
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderGrants() {
|
|
const el = document.getElementById("edit-grants");
|
|
if (!editing.device_grants.length) {
|
|
el.innerHTML = '<p class="hint">No device rights.</p>';
|
|
return;
|
|
}
|
|
const now = new Date().toISOString();
|
|
el.innerHTML = editing.device_grants
|
|
.map((g) => {
|
|
const expired = g.expires_at && g.expires_at <= now;
|
|
return `<div class="card compact${expired ? " expired" : ""}">
|
|
<span class="card-body">
|
|
<span class="card-name mono">${escapeHtml(g.ha_entity_id)}</span>
|
|
<span class="card-meta">${escapeHtml(g.permission)}${
|
|
g.expires_at ? ` · ${expired ? "expired" : "until"} ${escapeHtml(fmtDate(g.expires_at))}` : " · no expiry"
|
|
}${g.note ? ` · ${escapeHtml(g.note)}` : ""}</span>
|
|
</span>
|
|
<button type="button" class="btn danger small" data-drop-grant="${g.id}">Revoke</button>
|
|
</div>`;
|
|
})
|
|
.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(),
|
|
})
|
|
.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) =>
|
|
`<label class="card compact">
|
|
<input type="checkbox" value="${p.id}" checked>
|
|
<span class="card-body">
|
|
<span class="card-name">${escapeHtml(p.name)}${p.nickname ? ` “${escapeHtml(p.nickname)}”` : ""}</span>
|
|
<span class="card-meta">last visit ${escapeHtml(fmtDate(p.last_visit_at))}${
|
|
p.last_visit_is_estimated ? " (never actually recorded — registered then)" : ""
|
|
} · ${p.visit_count} visit${p.visit_count === 1 ? "" : "s"}</span>
|
|
</span>
|
|
</label>`
|
|
)
|
|
.join("");
|
|
pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton));
|
|
syncPruneButton();
|
|
})
|
|
.catch((err) => {
|
|
document.getElementById("prune-summary").innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`;
|
|
});
|
|
});
|
|
|
|
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 = `<span class="error">${escapeHtml(err.message)}</span>`;
|
|
});
|
|
});
|
|
|
|
// --- History --------------------------------------------------------------------------
|
|
function populatePersonSelect() {
|
|
const select = document.getElementById("history-person");
|
|
const current = select.value;
|
|
select.innerHTML =
|
|
'<option value="">Everyone</option>' +
|
|
people.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).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 = '<p class="hint">Loading…</p>';
|
|
api(visitPath)
|
|
.then((data) => {
|
|
const visits = data.visits || [];
|
|
if (!visits.length) {
|
|
el.innerHTML = '<p class="hint">No visits recorded in that range.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = visits
|
|
.map(
|
|
(v) =>
|
|
`<div class="card compact">
|
|
<span class="card-body">
|
|
<span class="card-name">${escapeHtml(v.name)}</span>
|
|
<span class="card-meta">${escapeHtml(fmt(v.arrived_at))} → ${
|
|
v.ongoing ? "still home" : escapeHtml(fmt(v.departed_at))
|
|
} · ${escapeHtml(duration(v.arrived_at, v.departed_at))} · via ${escapeHtml(v.source)}${
|
|
v.close_reason === "timed_out" ? " · departure never observed" : ""
|
|
}</span>
|
|
</span>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
})
|
|
.catch((err) => {
|
|
el.innerHTML = `<p class="error">Could not load visits: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
|
|
const copEl = document.getElementById("copresence-list");
|
|
const copQuery = new URLSearchParams();
|
|
if (personId) copQuery.set("person_id", personId);
|
|
if (since) copQuery.set("since", since);
|
|
api(`/co-presence?${copQuery.toString()}`)
|
|
.then((data) => {
|
|
const overlaps = data.overlaps || [];
|
|
if (!overlaps.length) {
|
|
copEl.innerHTML = '<p class="hint">Nobody overlapped in that range.</p>';
|
|
return;
|
|
}
|
|
copEl.innerHTML = overlaps
|
|
.map(
|
|
(o) =>
|
|
`<div class="card compact">
|
|
<span class="card-body">
|
|
<span class="card-name">${escapeHtml(o.people.map((p) => p.name).join(" + "))}</span>
|
|
<span class="card-meta">${escapeHtml(fmt(o.from))} → ${
|
|
o.ongoing ? "now" : escapeHtml(fmt(o.until))
|
|
} · ${escapeHtml(duration(o.from, o.ongoing ? null : o.until))}</span>
|
|
</span>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
})
|
|
.catch((err) => {
|
|
copEl.innerHTML = `<p class="error">Could not load co-presence: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
document.getElementById("history-load").addEventListener("click", loadHistory);
|
|
|
|
// --- Access log ------------------------------------------------------------------------
|
|
function loadAccessLog() {
|
|
const el = document.getElementById("access-list");
|
|
api("/device-access/events?limit=200")
|
|
.then((data) => {
|
|
const events = data.events || [];
|
|
if (!events.length) {
|
|
el.innerHTML = '<p class="hint">Nothing has asked yet.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = events
|
|
.map(
|
|
(e) =>
|
|
`<div class="card compact ${e.allowed ? "allowed" : "denied"}">
|
|
<span class="card-body">
|
|
<span class="card-name">${escapeHtml(e.name || `person ${e.person_id}`)} → <span class="mono">${escapeHtml(e.ha_entity_id)}</span></span>
|
|
<span class="card-meta">${escapeHtml(fmt(e.created_at))} · ${escapeHtml(e.permission)} · ${escapeHtml(e.reason)}${
|
|
e.requested_via ? ` · via ${escapeHtml(e.requested_via)}` : ""
|
|
}</span>
|
|
</span>
|
|
<span class="verdict">${e.allowed ? "allowed" : "denied"}</span>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
})
|
|
.catch((err) => {
|
|
el.innerHTML = `<p class="error">Could not load access log: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
loadPeople();
|