This commit is contained in:
Home Assistant Version Control committed 2026-08-31 14:54:46 +00:00
1 parent 38964507bd
commit 1770e33ddc
28 files changed
+692 -232

No files matched your search

@@ -17,6 +17,13 @@ const css = LitElement.prototype.css;
const tmSafePhotoUrl = (u) =>
typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : "";
// A pending-claims attribute is only ever usable as a list. The resolver used
// to hand back the pending-approvals sensor's scalar COUNT under this name,
// which turned .filter()/.some() into a TypeError and blanked the whole card
// (#834). The name collision is fixed in the resolver; this keeps a stray
// value from taking the card down again.
const tmClaimList = (v) => (Array.isArray(v) ? v : []);
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
class TaskMateApprovalsCard extends LitElement {
@@ -25,6 +32,7 @@ class TaskMateApprovalsCard extends LitElement {
hass: { type: Object },
config: { type: Object },
_loading: { type: Object },
_signed: { state: true },
};
}
@@ -40,6 +48,8 @@ class TaskMateApprovalsCard extends LitElement {
constructor() {
super();
this._loading = {};
this._signed = {}; // safe photo path -> per-viewer signed path
this._inflight = new Set();
}
_t(key, params) {
@@ -47,6 +57,56 @@ class TaskMateApprovalsCard extends LitElement {
return fn ? fn(this.hass, key, params) : key;
}
// Evidence photos are served from an auth-gated view; an <img> carries no
// bearer token, so each path is signed per-viewer via auth/sign_path. The
// sensor now publishes bare paths (no self-authenticating URL in shared
// state), so this binds photo access to the actual viewer.
_photoHref(raw) {
const safe = tmSafePhotoUrl(raw);
return (safe && this._signed[safe]) || "";
}
_collectPhotoUrls() {
const out = [];
const st = this.hass && this.hass.states;
if (!st) return out;
for (const eid in st) {
if (eid.indexOf("sensor.taskmate") !== 0) continue;
const attrs = (st[eid] && st[eid].attributes) || {};
for (const k in attrs) {
const v = attrs[k];
if (!Array.isArray(v)) continue;
for (const item of v) {
if (item && typeof item === "object" && typeof item.photo_url === "string" && item.photo_url) {
out.push(item.photo_url);
}
}
}
}
return out;
}
async _ensureSignedPhotos(rawUrls) {
for (const raw of rawUrls) {
const url = tmSafePhotoUrl(raw);
if (!url || this._signed[url] || this._inflight.has(url)) continue;
this._inflight.add(url);
try {
const res = await this.hass.callWS({ type: "auth/sign_path", path: url, expires: 3600 });
if (res && res.path) this._signed = { ...this._signed, [url]: res.path };
} catch (e) {
console.warn("taskmate: sign_path failed", e);
} finally {
this._inflight.delete(url);
}
}
}
updated() {
const urls = this._collectPhotoUrls();
if (urls.length) this._ensureSignedPhotos(urls);
}
static get styles() {
const base = css`
:host {
@@ -530,10 +590,9 @@ class TaskMateApprovalsCard extends LitElement {
// Pending reward claims: supported via either the pending_approvals sensor
// (reward_claims attribute) or the rewards sensor (pending_reward_claims).
let rewardClaims =
entity.attributes.reward_claims ||
attrs.pending_reward_claims ||
[];
let rewardClaims = tmClaimList(
entity.attributes.reward_claims || attrs.pending_reward_claims,
);
const filteredClaims = this._filterClaimsByChild(rewardClaims);
// Missed mandatory chores awaiting parent review (#532)
@@ -611,7 +670,7 @@ class TaskMateApprovalsCard extends LitElement {
if (!completions) completions = (attrs.todays_completions || []).filter(c => !c.approved);
const filteredCompletions = this._filterByChild(completions);
const rewardClaims = entity.attributes.reward_claims || attrs.pending_reward_claims || [];
const rewardClaims = tmClaimList(entity.attributes.reward_claims || attrs.pending_reward_claims);
const filteredClaims = this._filterClaimsByChild(rewardClaims);
const misses = this._filterMissesByChild(attrs.mandatory_misses || []);
@@ -805,7 +864,7 @@ class TaskMateApprovalsCard extends LitElement {
}
_apPhotoDesigned(it, cls) {
const photoUrl = tmSafePhotoUrl(it.photo);
const photoUrl = this._photoHref(it.photo);
if (it.kind !== "completion" || !photoUrl) {
return it.kind === "claim" && it.icon
? html`<div class="${cls}"><ha-icon icon="${it.icon}"></ha-icon></div>`
@@ -1332,11 +1391,11 @@ class TaskMateApprovalsCard extends LitElement {
${completion.points}
</span>
</div>
${tmSafePhotoUrl(completion.photo_url) ? html`
<a class="approval-photo" href="${tmSafePhotoUrl(completion.photo_url)}" target="_blank" rel="noopener"
@click="${(e) => this._openPhoto(e, tmSafePhotoUrl(completion.photo_url), this._photoCaption(completion.chore_name, completion.child_name, completion.completed_at))}"
${this._photoHref(completion.photo_url) ? html`
<a class="approval-photo" href="${this._photoHref(completion.photo_url)}" target="_blank" rel="noopener"
@click="${(e) => this._openPhoto(e, this._photoHref(completion.photo_url), this._photoCaption(completion.chore_name, completion.child_name, completion.completed_at))}"
title="${this._t('approvals.view_photo') || 'View photo'}">
<img src="${tmSafePhotoUrl(completion.photo_url)}" alt="" loading="lazy">
<img src="${this._photoHref(completion.photo_url)}" alt="" loading="lazy">
</a>
` : ''}
</div>