Dashboard Studio 2.0.0: Admin, Builder and Dashboard screens, roles, Haven

- Home Assistant administrators are admins: Dashboard, Builder and Admin screens
- Everyone else only sees the dashboards an admin chose, always in kiosk mode;
  the server enforces it (saving, people, devices, history are admin-only, and
  the websocket only allows reading states and controlling devices)
- Admin screen: dashboards, people and what each sees (with preview), devices,
  messages to the screens, the Haven app, activity, about
- Haven: new Android app (haven/) that only shows dashboards; replaces the
  Dashboard Studio Android app and signs in through Home Assistant's page
- Screens pick up dashboard and access changes within a minute
- Version history records who made each change; panel visible to all users

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:20:55 +01:00
parent 297fb2a7a0
commit 3d958d0411
37 changed files with 1096 additions and 248 deletions
+432 -79
View File
@@ -246,9 +246,14 @@
let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp.
const IN_APP = !!window.DSApp;
const APK_ID = "nl.melvanveen.dashboardstudio";
// Haven, the Android app (haven/ in the repository), shows this page in its own window and adds window.HavenApp.
// It only ever shows dashboards: no builder and no admin screen, for admins too.
const IN_APP = !!window.HavenApp;
const HAVEN_ID = "nl.melvanveen.haven";
// The public download in the repository, so a phone can get Haven before it is signed in to Home Assistant.
const HAVEN_DOWNLOAD = `${REPO_URL}/raw/branch/main/dashboard_studio/web/haven.apk`;
const havenLink = () => new URL("./", BASE).href;
const havenIntent = () => `intent://open?url=${encodeURIComponent(havenLink())}#Intent;scheme=haven;package=${HAVEN_ID};end`;
async function installDialog() {
let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; }
@@ -260,17 +265,17 @@
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
if (IN_APP) {
body = p(tr("This is the Dashboard Studio app, connected to {0}.", location.host));
body = p(tr("This is Haven, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
} else if (android) {
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: the Android app always works.
body = p(tr("Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : "");
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works.
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download Haven and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in Haven. Or open Haven and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without Haven."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("android.apk", BASE).href}" download="Dashboard Studio.apk">${tr("Download the app")}</a>`
+ `<a class="btn primary" target="_top" href="intent://open?url=${encodeURIComponent(link)}#Intent;scheme=dashboardstudio;package=${APK_ID};end">${tr("Open in the app")}</a>`;
+ `<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${tr("Download Haven")}</a>`
+ `<a class="btn primary" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a>`;
} else if (ios) {
// Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
@@ -283,18 +288,34 @@
body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
<div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
if (choice === "reset") window.DSApp.reset();
if (choice === "reset") window.HavenApp.reset();
}
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
* after a while requests would fail. Renew it the way Home Assistant's own page does, with the
* login that page saved in this browser. Inside Home Assistant this isn't needed. */
function wsSession(token) {
return new Promise((resolve) => {
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/websocket`);
const done = (v) => { clearTimeout(t); try { ws.close(); } catch { /* closed */ } resolve(v); };
const t = setTimeout(() => done(null), 10000);
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === "auth_required") ws.send(JSON.stringify({ type: "auth", access_token: token }));
else if (m.type === "auth_ok") ws.send(JSON.stringify({ id: 1, type: "supervisor/api", endpoint: "/ingress/session", method: "post" }));
else if (m.id === 1) done(m.success ? m.result?.session : null);
else if (m.type === "auth_invalid") done(null);
};
ws.onerror = () => done(null);
});
}
async function keepIngressSession() {
let top = true;
try { top = window.top === window; } catch { top = false; }
@@ -311,8 +332,8 @@
localStorage.setItem("hassTokens", JSON.stringify(tokens));
}
const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } });
if (!r.ok) return;
const session = (await r.json())?.data?.session;
// Only administrators may use that address; Home Assistant's websocket gives everyone a session.
const session = r.ok ? (await r.json())?.data?.session : await wsSession(tokens.access_token);
if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
};
try { await renew(); } catch { /* offline: the cached page carries on */ }
@@ -647,6 +668,16 @@
// Each browser gets its own id, so a wall tablet can be told which dashboard to open.
const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })();
let me = null; // the signed-in Home Assistant user, from the server
// Admins (Home Assistant's administrators) get three screens: Dashboard, Builder and Admin. Everyone else
// only sees the dashboards an admin chose for them, always in kiosk mode. The server enforces the same.
let role = lsGet("ds.role") === "basic" ? "basic" : "admin";
const isAdmin = () => role === "admin";
let mode = "dashboard"; // dashboard, builder or admin
let previewAs = null; // Admin → People → Preview: { id, name, ids }
let lockedKiosk = false; // people who aren't admins can't leave kiosk mode
/** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */
const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true };
const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); };
let current = null;
let editMode = lsGet("ds.edit") === "1";
let selectedId = null;
@@ -681,6 +712,7 @@
// ------------------------------------------------------------ persistence
let saveTimer = null, savePending = false;
function scheduleSave() {
if (!isAdmin()) return; // the server refuses it anyway
savePending = true;
$("#save-status").textContent = tr("Saving…");
clearTimeout(saveTimer);
@@ -801,7 +833,7 @@
s.setProperty("--d-radius", `${t.radius ?? 20}px`);
const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
window.DSApp?.setBarColor(bar);
window.HavenApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`);
@@ -1051,7 +1083,7 @@
}
function renderDashSelect() {
$("#dash-select").innerHTML = store.dashboards.map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join("");
$("#dash-select").innerHTML = store.dashboards.filter((d) => !previewAs || previewAs.ids.includes(d.id)).map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join("");
}
// ------------------------------------------------------------ suggestions
@@ -1129,7 +1161,7 @@
function renderAll() {
document.body.classList.toggle("editing", editMode);
$("#btn-edit").textContent = editMode ? tr("Done") : tr("Edit");
renderModeSeg();
renderDashSelect();
renderGrid();
if (editMode) renderInspector();
@@ -2372,50 +2404,6 @@
});
}
/** Who sees which dashboard: this device, other known devices, and people. */
async function assignDialog() {
const users = await api("api/users").catch(() => []);
const people = new Map();
for (const s of Object.values(ha.states)) if (s.entity_id.startsWith("person.") && s.attributes.user_id) people.set(s.attributes.user_id, friendly(s));
for (const u of users) if (!people.has(u.id)) people.set(u.id, u.display || u.name || u.id);
const a = store.assign;
const opts = (val) => `<option value="">${tr("Last used")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const devRow = (id, d, mine) => `<div class="assign-row" data-dev="${id}">
<input class="input" data-a="name" value="${esc(d?.name || "")}" placeholder="${mine ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<select class="input" data-a="dashboard" aria-label="${tr("Dashboard")}">${opts(d?.dashboard)}</select>
<label class="assign-kiosk"><input type="checkbox" data-a="kiosk" ${d?.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${mine ? "" : `<button class="btn icon" data-x="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
const others = Object.entries(a.devices).filter(([id]) => id !== DEVICE_ID);
const html = `<h3>${tr("Who sees which dashboard")}</h3>
<p>${tr("Choose the dashboard that opens first. A device’s choice comes before a person’s; otherwise the last dashboard used on that device opens.")}</p>
<div class="section-title" style="margin-top:16px">${tr("This device")}</div>${devRow(DEVICE_ID, a.devices[DEVICE_ID], true)}
${others.length ? `<div class="section-title" style="margin-top:16px">${tr("Other devices")}</div>${others.map(([id, d]) => devRow(id, d, false)).join("")}` : ""}
<div class="section-title" style="margin-top:16px">${tr("People")}</div>
${people.size ? [...people].map(([id, name]) => `<div class="assign-row" data-user="${esc(id)}"><span class="assign-name">${esc(name)}${me?.id === id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</span>
<select class="input" data-a="dashboard" aria-label="${esc(tr("Dashboard for {0}", name))}">${opts(a.users[id])}</select></div>`).join("") : `<p class="muted small">${tr("People appear here once they have opened Dashboard Studio, or when they have a person entity.")}</p>`}
<div class="modal-actions"><button class="btn primary" data-x="done">${tr("Done")}</button></div>`;
const card = $("#modal .modal-card");
const done = openModal(html, (e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "forget") { delete a.devices[e.target.closest("[data-dev]").dataset.dev]; e.target.closest(".assign-row").remove(); scheduleSave(); }
if (x === "done") close();
});
card.classList.add("wide");
const update = (e) => {
const f = e.target, key = f.dataset.a, row = f.closest(".assign-row");
if (!key || !row) return;
const v = f.type === "checkbox" ? f.checked : f.value;
if (row.dataset.user) { if (v) a.users[row.dataset.user] = v; else delete a.users[row.dataset.user]; }
else { const d = (a.devices[row.dataset.dev] ||= {}); d[key] = v; }
scheduleSave();
};
card.addEventListener("change", update);
card.addEventListener("input", (e) => { if (e.target.dataset.a === "name") update(e); });
await done;
card.classList.remove("wide");
card.removeEventListener("change", update);
}
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
async function whatsNew() {
let md = "";
@@ -2481,7 +2469,7 @@
async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
window.HavenApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
}
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
@@ -2496,17 +2484,18 @@
history.replaceState(null, "", `#kiosk&view=${current.id}`);
renderGrid();
poke();
toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
if (!lockedKiosk) toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
}
function exitKiosk() {
if (lockedKiosk) return;
kiosk = false;
document.body.classList.remove("kiosk", "idle");
hideSaver();
closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null;
window.DSApp?.keepAwake(false);
window.HavenApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname);
renderGrid();
@@ -2523,13 +2512,20 @@
async function kioskPrompt() {
if (promptOpen || modalClose) return;
promptOpen = true;
const timer = setTimeout(() => modalClose?.(null), PROMPT_MS);
const choice = await openModal(`<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
const timer = setTimeout(() => modalClose?.(null), PROMPT_MS * (lockedKiosk ? 2 : 1));
// People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see.
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
clearTimeout(timer);
promptOpen = false;
if (choice === "exit") exitKiosk();
if (choice?.startsWith("dash:")) { setCurrent(choice.slice(5)); lsSet("ds.current", current.id); }
else if (choice === "exit") exitKiosk();
else if (choice === "haven") window.HavenApp.reset();
else if (choice === "full") toggleFullscreen(false);
else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true);
}
@@ -2938,10 +2934,60 @@
renderAll();
checkGuest(true);
checkAway();
if (mode === "admin") renderAdmin();
}
// ------------------------------------------------------------ screens
function renderModeSeg() {
const seg = $("#mode-seg");
seg.hidden = !isAdmin() || IN_APP || !!previewAs;
seg.querySelectorAll("[data-mode]").forEach((b) => { const on = b.dataset.mode === mode; b.classList.toggle("on", on); b.setAttribute("aria-selected", String(on)); });
}
function syncMode() {
document.body.classList.toggle("admin-mode", mode === "admin");
$("#admin").hidden = mode !== "admin";
if (!previewAs) lsSet("ds.mode", mode);
}
function setMode(m) {
if (!isAdmin() || IN_APP || previewAs) m = "dashboard";
if (m === "admin") {
if (kiosk) exitKiosk();
if (editMode) { editMode = false; openSheet(null); selectedId = null; multi.clear(); }
mode = "admin";
syncMode();
renderAll();
renderAdmin();
return;
}
mode = m;
setEdit(m === "builder");
}
/** Dashboards from before 2.0 had "opens first" per person in assign.users; that's now part of access. */
function migrateAccess() {
store.access ||= { everyone: { all: true }, users: {} };
store.access.users ||= {};
const old = store.assign?.users || {};
if (!Object.keys(old).length) return;
for (const [u, id] of Object.entries(old)) store.access.users[u] ||= { all: true, home: id };
store.assign.users = {};
scheduleSave();
}
/** People who aren't admins, with no dashboard chosen for them yet. */
function showEmpty() {
const el = $("#empty-note");
el.querySelector(".en-ico").innerHTML = Icons.svg("layout");
el.querySelector(".en-text").textContent = tr("An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio.");
el.hidden = false;
document.body.classList.add("empty");
}
function setEdit(on) {
editMode = on;
if (on) mode = "builder";
else if (mode === "builder") mode = "dashboard";
syncMode();
if (!on) openSheet(null);
else renderPalette(); // the suggestions count needs the states
lsSet("ds.edit", on ? "1" : "0");
@@ -2985,10 +3031,10 @@
if (open) $("#menu button")?.focus();
}
$("#btn-edit").onclick = () => setEdit(!editMode);
$("#mode-seg").onclick = (e) => { const b = e.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); };
$("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = IN_APP || standalone();
$("#btn-app").hidden = standalone() && !IN_APP; // in Haven it holds Change address
$("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value);
@@ -3009,11 +3055,14 @@
const act = e.target.closest("[data-menu]")?.dataset.menu;
if (!act) return;
setMenu(false);
dashboardAction(act);
});
/** What can be done with the current dashboard, from the builder's ⋯ menu or a card on the Admin screen. */
async function dashboardAction(act) {
if (act === "rename") {
const n = await askText(tr("Rename dashboard"), current.name, tr("Rename"));
if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); }
} else if (act === "assign") {
assignDialog();
} else if (act === "history") {
historyDialog();
} else if (act === "undo-history") {
@@ -3051,7 +3100,7 @@
store.dashboards = store.dashboards.filter((d) => d.id !== current.id);
scheduleSave(); setCurrent(store.dashboards[0].id);
}
});
}
$("#import-file").onchange = async (e) => {
const file = e.target.files[0];
@@ -3227,6 +3276,273 @@
setInterval(() => { applyNight(); checkGuest(); }, 60000);
setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000);
// ------------------------------------------------------------ admin screen
// Dashboards, the people who use them and what each may see, devices, Haven, and who changed what.
const ADMIN_SECTIONS = [
["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"],
["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
];
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
let peopleList = null, devicesSeen = null;
const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never"));
const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase());
async function loadPeople(force) {
if (!peopleList || force) peopleList = await api("api/people").catch(() => []);
return peopleList;
}
/** Who may see a dashboard, in a few words. */
function seenByText(d) {
const basics = (peopleList || []).filter((p) => !p.admin);
const who = basics.filter((p) => visibleFor(p.id).includes(d.id));
if (!basics.length) return tr("Admins");
if (who.length === basics.length) return tr("Everyone");
if (!who.length) return tr("Admins only");
return who.map((p) => p.name).join(", ");
}
const ADMIN = {
async dashboards() {
await loadPeople();
const cards = store.dashboards.map((d) => {
const widgets = d.tabs.reduce((n, t) => n + t.widgets.length, 0);
const bg = /^#|gradient/.test(d.theme?.bg || "") && d.theme.preset !== "System" ? d.theme.bg : "var(--fill)";
return `<article class="adm-card" data-dash="${d.id}">
<div class="ac-top"><div class="ac-swatch" style="background:${esc(bg)}">${Icons.svg(d.mobile ? "phone" : "layout")}</div>
<div class="grow"><h3>${esc(d.name)}</h3><div class="muted small">${d.tabs.length === 1 ? tr("1 tab") : tr("{0} tabs", d.tabs.length)} · ${widgets === 1 ? tr("1 widget") : tr("{0} widgets", widgets)}${d.mobile ? ` · ${tr("Phone")}` : ""}</div></div></div>
<div class="ac-who">${Icons.svg("people")}<span>${esc(seenByText(d))}</span></div>
<div class="ac-actions"><button class="btn" data-a="open">${tr("Open")}</button><button class="btn primary" data-a="edit">${Icons.svg("edit")}${tr("Build")}</button>
<button class="btn icon" data-a="more" aria-label="${esc(tr("More for {0}", d.name))}">${Icons.svg("more")}</button></div>
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("Dashboards")}</h2><p class="muted">${tr("Build a dashboard, then choose under People who sees it.")}</p></div>
<div class="adm-actions"><button class="btn primary" data-a="new">${Icons.svg("plus")}${tr("New dashboard")}</button>
<button class="btn" data-a="starter">${Icons.svg("sparkles")}${tr("From my devices")}</button>
<button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import file")}</button>
<button class="btn" data-a="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header>
<div class="adm-cards">${cards}</div>`;
},
async people() {
const list = await loadPeople(true);
const acc = store.access;
const dashChecks = (key, entry) => store.dashboards.map((d) => `<label class="chip-check"><input type="checkbox" data-k="dash" data-who="${key}" value="${d.id}" ${entry.all || (entry.dashboards || []).includes(d.id) ? "checked" : ""} ${entry.all ? "disabled" : ""}><span>${esc(d.name)}</span></label>`).join("");
const homeSel = (key, entry) => `<select class="input" data-k="home" data-who="${key}" aria-label="${tr("Opens first")}"><option value="">${tr("The first one")}</option>${store.dashboards.filter((d) => entry.all || (entry.dashboards || []).includes(d.id)).map((d) => `<option value="${d.id}" ${entry.home === d.id ? "selected" : ""}>${esc(d.name)}</option>`).join("")}</select>`;
const accessBlock = (key, entry, own) => `<div class="p-access">
<div class="seg seg-sm" role="radiogroup" aria-label="${tr("Sees")}">
${key !== "everyone" ? `<button data-k="mode" data-who="${key}" data-v="same" aria-pressed="${!own}">${tr("Like everyone else")}</button>` : ""}
<button data-k="mode" data-who="${key}" data-v="all" aria-pressed="${(own || key === "everyone") && !!entry.all}">${tr("All dashboards")}</button>
<button data-k="mode" data-who="${key}" data-v="some" aria-pressed="${(own || key === "everyone") && !entry.all}">${tr("Only these")}</button></div>
${(own || key === "everyone") && !entry.all ? `<div class="adm-chips">${dashChecks(key, entry)}</div>` : ""}
${own || key === "everyone" ? `<label class="field"><span>${tr("Opens first")}</span>${homeSel(key, entry)}</label>` : ""}
</div>`;
const rows = list.map((p) => {
const own = acc.users[p.id];
const entry = own || acc.everyone || { all: true };
return `<article class="person" data-user="${esc(p.id)}">
<div class="p-head"><span class="adm-avatar ${p.admin ? "admin" : ""}">${initials(p.name)}</span>
<div class="grow"><div class="p-name">${esc(p.name)} ${p.admin ? `<span class="p-badge">${tr("Admin")}</span>` : ""}${me?.id === p.id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</div>
<div class="muted small">${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}</div></div>
${p.admin ? "" : `<button class="btn" data-a="preview">${Icons.svg("eye")}<span class="label-wide">${tr("Preview")}</span></button>`}</div>
${p.admin ? "" : accessBlock(p.id, entry, !!own)}
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("People")}</h2>
<p class="muted">${tr("Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.")}</p></div></header>
<article class="person everyone"><div class="p-head"><span class="adm-avatar">${Icons.svg("people")}</span>
<div class="grow"><div class="p-name">${tr("Everyone else")}</div><div class="muted small">${tr("People without their own choice below, and anyone added to Home Assistant later.")}</div></div></div>
${accessBlock("everyone", acc.everyone || { all: true }, true)}</article>
${rows || `<p class="muted">${tr("Couldn’t read Home Assistant’s people.")}</p>`}
<p class="muted small">${tr("To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.")}</p>`;
},
async devices() {
[devicesSeen] = await Promise.all([api("api/devices").catch(() => ({})), loadPeople()]);
const a = store.assign.devices;
const ids = [...new Set([DEVICE_ID, ...Object.keys(devicesSeen), ...Object.keys(a)])]
.sort((x, y) => (x === DEVICE_ID ? -1 : y === DEVICE_ID ? 1 : (devicesSeen[y]?.seen || 0) - (devicesSeen[x]?.seen || 0)));
const opts = (val) => `<option value="">${tr("The person’s choice")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const rows = ids.map((id) => {
const seen = devicesSeen[id] || {}, set = a[id] || {};
const icon = { phone: "phone", tablet: "tv", computer: "laptop" }[seen.kind] || "laptop";
const what = [seen.display || (id === DEVICE_ID ? me?.display : ""), seen.app === "haven" ? "Haven" : seen.app ? tr("Browser") : "", seen.seen ? ago(seen.seen) : ""].filter(Boolean).join(" · ");
return `<div class="dev-row" data-dev="${id}"><span class="adm-avatar">${Icons.svg(icon)}</span>
<div class="grow"><input class="input" data-k="name" value="${esc(set.name || "")}" placeholder="${id === DEVICE_ID ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<div class="muted small">${id === DEVICE_ID ? `<b>${tr("This device")}</b> · ` : ""}${esc(what)}</div></div>
<select class="input" data-k="dashboard" aria-label="${tr("Dashboard")}">${opts(set.dashboard)}</select>
<label class="assign-kiosk" title="${tr("Admins on this device start in kiosk mode too")}"><input type="checkbox" data-k="kiosk" ${set.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${id === DEVICE_ID ? `<span class="dev-gap"></span>` : `<button class="btn icon" data-a="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
}).join("");
const named = ids.filter((id) => a[id]?.name);
return `<header class="adm-head"><div><h2>${tr("Devices")}</h2>
<p class="muted">${tr("Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.")}</p></div></header>
<div class="adm-list">${rows}</div>
<section class="adm-panel"><h3>${Icons.svg("megaphone")}${tr("Send a message to the screens")}</h3>
<p class="muted small">${tr("It shows full screen on dashboards in kiosk mode, like an announcement from an automation.")}</p>
<div class="an-form"><input class="input" id="an-title" placeholder="${tr("Title, e.g. Dinner")}" aria-label="${tr("Title")}">
<input class="input" id="an-msg" placeholder="${tr("Message, e.g. Dinner is ready!")}" aria-label="${tr("Message")}">
<select class="input" id="an-to" aria-label="${tr("To")}"><option value="">${tr("All screens")}</option>${named.map((id) => `<option value="${id}">${esc(a[id].name)}</option>`).join("")}</select>
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
},
async haven() {
const addr = location.origin;
return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2>
<p class="muted">${tr("Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.")}</p></div></header>
<div class="haven-grid">
<section class="adm-panel haven-step"><div class="hs-num">1</div><h3>${tr("Install Haven on the phone")}</h3>
<p class="muted small">${tr("Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.")}</p>
<div class="share-qr">${window.QR.svg(HAVEN_DOWNLOAD, { label: tr("QR code to download Haven") })}</div>
<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${Icons.svg("arrow-down")}${tr("Download Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">2</div><h3>${tr("Connect to Home Assistant")}</h3>
<p class="muted small">${tr("Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.")}</p>
<div class="share-row"><input class="input" id="haven-addr" readonly value="${esc(addr)}" aria-label="${tr("Address")}"><button class="btn" data-a="copy-addr">${Icons.svg("copy")}${tr("Copy")}</button></div>
<p class="muted small">${tr("On the phone itself, Open in Haven fills it in.")}</p>
<a class="btn" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">3</div><h3>${tr("Sign in")}</h3>
<p class="muted small">${tr("Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.")}</p></section>
</div>
<p class="muted small">${tr("Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.")}</p>`;
},
async activity() {
const rows = await api("api/activity").catch(() => []);
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
return `<header class="adm-head"><div><h2>${tr("Activity")}</h2><p class="muted">${tr("Who changed which dashboard. Restore an earlier version from the dashboard’s version history.")}</p></div></header>
<div class="adm-list">${rows.map((r) => `<div class="act-row" ${r.deleted ? "" : `data-dash="${r.dashboard}"`}>
<span class="adm-avatar">${r.by ? initials(r.by) : Icons.svg("edit")}</span>
<div class="grow"><div>${r.by ? tr("{0} changed {1}", `<b>${esc(r.by)}</b>`, `<b>${esc(r.name)}</b>`) : tr("{0} was changed", `<b>${esc(r.name)}</b>`)}${r.deleted ? ` <span class="muted small">(${tr("deleted")})</span>` : ""}</div>
<div class="muted small">${esc(when(r.at))} · ${esc(ago(r.at))}</div></div>
${r.deleted ? "" : `<button class="btn" data-a="history">${tr("Versions")}</button>`}</div>`).join("") || `<p class="muted">${tr("No changes yet.")}</p>`}</div>`;
},
async about() {
return `<header class="adm-head"><div><h2>${tr("About")}</h2></div></header>
<section class="adm-panel"><h3>Dashboard Studio ${esc(APP_VERSION)}</h3>
<p class="muted">${tr("You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.", esc(me?.display || me?.name || ""))}</p>
<div class="modal-actions" style="justify-content:flex-start"><button class="btn" data-a="whatsnew">${Icons.svg("sparkles")}${tr("What’s new")}</button>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener">${Icons.svg("globe")}${tr("Source code")}</a></div></section>`;
},
};
async function renderAdmin(section = adminSection) {
adminSection = section;
lsSet("ds.admin", section);
$("#admin .adm-nav").innerHTML = ADMIN_SECTIONS.map(([k, icon, label]) => `<button role="tab" data-sec="${k}" aria-selected="${k === section}" class="${k === section ? "on" : ""}">${Icons.svg(icon)}<span>${tr(label)}</span></button>`).join("");
const body = $("#admin .adm-body");
const html = await ADMIN[section]();
if (adminSection !== section || mode !== "admin") return; // moved on while it loaded
const y = body.scrollTop;
body.innerHTML = html;
body.scrollTop = y;
}
/** Change what a person (or "everyone") may see. */
function setAccess(who, fn) {
const acc = store.access;
if (who === "everyone") acc.everyone = fn({ all: true, ...(acc.everyone || {}) });
else { const v = fn(acc.users[who] ? { ...acc.users[who] } : { ...(acc.everyone || { all: true }) }); if (v) acc.users[who] = v; else delete acc.users[who]; }
scheduleSave();
renderAdmin("people");
}
/** Admin → People → Preview: the dashboards this person sees, as they see them. */
function startPreview(p) {
const ids = visibleFor(p.id);
if (!ids.length) { toast(tr("{0} can’t see any dashboard yet.", p.name), true); return; }
const home = accessFor(p.id).home;
previewAs = { ...p, ids };
mode = "dashboard";
syncMode();
setCurrent(ids.includes(home) ? home : ids[0]);
setEdit(false);
const bar = $("#preview-bar");
bar.querySelector(".pv-text").textContent = tr("Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.", p.name, ids.length);
bar.hidden = false;
document.body.classList.add("previewing");
}
$("#pv-stop").onclick = () => {
previewAs = null;
$("#preview-bar").hidden = true;
document.body.classList.remove("previewing");
setMode("admin");
renderAdmin("people");
};
$("#admin").addEventListener("click", async (e) => {
const sec = e.target.closest("[data-sec]");
if (sec) { renderAdmin(sec.dataset.sec); $("#admin .adm-body").scrollTop = 0; return; }
const k = e.target.closest("button[data-k]");
if (k?.dataset.k === "mode") {
const v = k.dataset.v;
setAccess(k.dataset.who, (cur) => (v === "same" ? null : v === "all" ? { all: true, home: cur.home } : { all: false, dashboards: cur.all ? store.dashboards.map((d) => d.id) : cur.dashboards || [], home: cur.home }));
return;
}
const b = e.target.closest("[data-a]");
if (!b) return;
const act = b.dataset.a, card = b.closest("[data-dash]"), dash = card?.dataset.dash;
if (act === "open" || act === "edit") { setCurrent(dash); setMode(act === "edit" ? "builder" : "dashboard"); return; }
if (act === "more") {
const d = store.dashboards.find((x) => x.id === dash);
const pick = await openModal(`<h3>${esc(d.name)}</h3><div class="act-list">
${[["rename", "edit", "Rename…"], ["duplicate", "copy", "Duplicate"], ["history", "history", "Version history…"], ["share", "share", "Share…"], ["export", "exit", "Export as JSON"], ["delete", "trash", "Delete dashboard…"]]
.map(([a, i, l]) => `<button class="btn ${a === "delete" ? "danger" : ""}" data-x="${a}">${Icons.svg(i)}${tr(l)}</button>`).join("")}</div>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`,
(ev, close) => { const x = ev.target.closest("[data-x]")?.dataset.x; if (x) close(x === "close" ? null : x); });
if (!pick) return;
setCurrent(dash);
await dashboardAction(pick);
if (mode === "admin") renderAdmin();
return;
}
if (act === "new") { $("#btn-new").click(); return; }
if (["starter", "import", "lovelace"].includes(act)) { await dashboardAction(act); if (act === "starter") renderAdmin(); return; }
if (act === "preview") { const p = peopleList.find((x) => x.id === b.closest("[data-user]").dataset.user); if (p) startPreview(p); return; }
if (act === "forget") {
const id = b.closest("[data-dev]").dataset.dev;
delete store.assign.devices[id];
scheduleSave();
await fetch(new URL(`api/devices/${encodeURIComponent(id)}`, BASE), { method: "DELETE" }).catch(() => {});
renderAdmin("devices");
return;
}
if (act === "announce") {
const message = $("#an-msg").value.trim();
if (!message) { $("#an-msg").focus(); return; }
const to = $("#an-to").value;
await ha.send({ type: "fire_event", event_type: "dashboard_studio_announce", event_data: { title: $("#an-title").value.trim() || undefined, message, ...(to ? { device: to } : {}) } })
.then(() => { toast(tr("Sent.")); $("#an-msg").value = ""; })
.catch((err) => toast(tr("Couldn’t send it: {0}", err.message), true));
return;
}
if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); return; }
if (act === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; }
if (act === "whatsnew") whatsNew();
});
$("#admin").addEventListener("change", (e) => {
const f = e.target, k = f.dataset.k;
if (f.closest("[data-dev]")) {
const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {});
d[k] = f.type === "checkbox" ? f.checked : f.value;
if (!d.name && !d.dashboard && !d.kiosk) delete store.assign.devices[id];
scheduleSave();
return;
}
if (k === "dash") {
const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value);
setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined }));
} else if (k === "home") setAccess(f.dataset.who, (cur) => ({ ...cur, home: f.value || undefined }));
});
// device names save while typing
$("#admin").addEventListener("input", (e) => {
const f = e.target;
if (f.dataset.k !== "name" || !f.closest("[data-dev]")) return;
const id = f.closest("[data-dev]").dataset.dev;
(store.assign.devices[id] ||= {}).name = f.value;
scheduleSave();
});
// ------------------------------------------------------------ boot
function paintChromeIcons() {
$("#btn-new").innerHTML = Icons.svg("plus");
@@ -3237,7 +3553,7 @@
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
$("#btn-app").innerHTML = Icons.svg("phone");
$("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
ha.onStatus = (ok) => {
@@ -3266,6 +3582,26 @@
setTimeout(() => location.reload(), 2500);
}
setInterval(checkVersion, 5 * 60 * 1000);
// Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload.
let storeKey = "";
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices]);
async function refreshStore() {
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
let next;
try { next = await api(`api/dashboards?device=${DEVICE_ID}`); } catch { return; }
const key = keyOf(next);
if (key === storeKey) return;
storeKey = key;
if (!$("#empty-note").hidden || (!isAdmin() && !next.dashboards?.length)) { location.reload(); return; }
next.dashboards = next.dashboards.map(migrate);
next.assign = { devices: {}, users: {}, ...(next.assign || {}) };
store = next;
const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id));
setCurrent(keep);
}
setInterval(refreshStore, 60 * 1000);
ha.onChange = onStateChange;
ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } };
@@ -3276,18 +3612,27 @@
}
async function boot() {
document.body.classList.toggle("in-haven", IN_APP);
translateStatic();
paintChromeIcons();
renderPalette();
await keepIngressSession();
// In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page.
if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; }
loadCachedStates();
if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
me = await api("api/whoami").catch(() => null);
if (me) { role = me.admin ? "admin" : "basic"; lsSet("ds.role", role); }
try {
const r = await fetch(new URL("api/dashboards", BASE));
const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
store = await r.json();
if (store.role) role = store.role;
store.dashboards = (store.dashboards || []).map(migrate);
store.assign = { devices: {}, users: {}, ...(store.assign || {}) };
} catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); }
// Let admins see this device on the Admin screen, to give it a dashboard.
fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {});
const firstReady = new Promise((res) => {
const prev = ha.onStatus;
@@ -3296,19 +3641,27 @@
});
ha.connect();
if (isAdmin()) migrateAccess();
storeKey = keyOf(store);
if (!store.dashboards?.length) {
if (!isAdmin()) { showEmpty(); return; }
await firstReady;
store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } };
store = { ...store, version: 1, dashboards: [starterDashboard()] };
save();
}
me = await api("api/whoami").catch(() => null);
const shared = sharedParams();
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds");
// Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here.
const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id];
const dev = store.assign.devices[DEVICE_ID], home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode);
const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device).