Updated Scheduler and Maintainance Apps
This commit is contained in:
@@ -297,6 +297,29 @@ async def _async_require_admin(hass: HomeAssistant, call: ServiceCall) -> None:
|
||||
raise Unauthorized(context=call.context)
|
||||
|
||||
|
||||
async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
|
||||
"""Reject user-initiated calls that aren't from an admin or a TaskMate parent.
|
||||
|
||||
Day-to-day parent actions (approve/reject, gift/adjust points, confirm
|
||||
rewards/allowance, award badges, complete-as-parent) accept non-admin HA
|
||||
users listed in ``parent_user_ids`` (issue #661). Context-less calls
|
||||
(automations, scripts) pass. Structural config stays on
|
||||
``_async_require_admin``.
|
||||
"""
|
||||
if not call.context.user_id:
|
||||
return
|
||||
user = await hass.auth.async_get_user(call.context.user_id)
|
||||
if user is None:
|
||||
raise Unauthorized(context=call.context)
|
||||
if user.is_admin:
|
||||
return
|
||||
coordinator = _get_coordinator(hass)
|
||||
parent_ids = coordinator.storage.get_parent_user_ids() if coordinator else []
|
||||
if call.context.user_id in parent_ids:
|
||||
return
|
||||
raise Unauthorized(context=call.context)
|
||||
|
||||
|
||||
_AUDIT_TARGET_KEYS = (
|
||||
"chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id",
|
||||
"task_group_id", "miss_id", "claim_id", "transaction_id", "type_id",
|
||||
@@ -405,6 +428,18 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
# Unauthorized (not ValueError), so it is unaffected and still 401s.
|
||||
return _safe(wrapped)
|
||||
|
||||
def _parent(handler):
|
||||
"""Like _admin, but also allows non-admin users in parent_user_ids (#661).
|
||||
|
||||
Used for day-to-day parent actions. Structural config keeps _admin.
|
||||
"""
|
||||
@wraps(handler)
|
||||
async def wrapped(call: ServiceCall) -> None:
|
||||
await _async_require_parent(hass, call)
|
||||
await handler(call)
|
||||
await _async_record_service_audit(hass, call)
|
||||
return _safe(wrapped)
|
||||
|
||||
async def handle_complete_chore(call: ServiceCall) -> None:
|
||||
"""Handle the complete_chore service call."""
|
||||
coordinator = _get_coordinator(hass)
|
||||
@@ -418,8 +453,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
# Completing on behalf of a child (auto-approve + instant award) is a
|
||||
# parent privilege. Enforce it on the backend, not just by hiding the
|
||||
# control in the UI — the service is callable by any authenticated
|
||||
# user. Normal child self-completion (as_parent omitted) stays open.
|
||||
await _async_require_admin(hass, call)
|
||||
# user. Admins and listed non-admin parents (#661) may do this;
|
||||
# normal child self-completion (as_parent omitted) stays open.
|
||||
await _async_require_parent(hass, call)
|
||||
else:
|
||||
await _async_require_linked_child(hass, call, coordinator, child_id)
|
||||
try:
|
||||
@@ -1038,7 +1074,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APPROVE_CHORE,
|
||||
_admin(handle_approve_chore),
|
||||
_parent(handle_approve_chore),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("completion_id"): cv.string,
|
||||
@@ -1049,7 +1085,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APPROVE_ALL_CHORES,
|
||||
_admin(handle_approve_all_chores),
|
||||
_parent(handle_approve_all_chores),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional("completion_ids"): [cv.string],
|
||||
@@ -1060,7 +1096,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REJECT_CHORE,
|
||||
_admin(handle_reject_chore),
|
||||
_parent(handle_reject_chore),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("completion_id"): cv.string,
|
||||
@@ -1071,21 +1107,21 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY,
|
||||
_admin(handle_apply_mandatory_penalty), schema=_miss_schema,
|
||||
_parent(handle_apply_mandatory_penalty), schema=_miss_schema,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE,
|
||||
_admin(handle_postpone_mandatory_chore), schema=_miss_schema,
|
||||
_parent(handle_postpone_mandatory_chore), schema=_miss_schema,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE,
|
||||
_admin(handle_dismiss_mandatory_chore), schema=_miss_schema,
|
||||
_parent(handle_dismiss_mandatory_chore), schema=_miss_schema,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UNDO_TRANSACTION,
|
||||
_admin(handle_undo_transaction),
|
||||
_parent(handle_undo_transaction),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("transaction_id"): cv.string,
|
||||
@@ -1096,7 +1132,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UNDO_CHORE_APPROVAL,
|
||||
_admin(handle_undo_chore_approval),
|
||||
_parent(handle_undo_chore_approval),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("completion_id"): cv.string,
|
||||
@@ -1118,7 +1154,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GIFT_POINTS,
|
||||
_admin(handle_gift_points),
|
||||
_parent(handle_gift_points),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("from_child_id"): cv.string,
|
||||
@@ -1131,7 +1167,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RECORD_ALLOWANCE_PAYOUT,
|
||||
_admin(handle_record_allowance_payout),
|
||||
_parent(handle_record_allowance_payout),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): cv.string,
|
||||
@@ -1179,14 +1215,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REJECT_REWARD,
|
||||
_admin(handle_reject_reward),
|
||||
_parent(handle_reject_reward),
|
||||
schema=vol.Schema({ vol.Required("claim_id"): cv.string }),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APPROVE_REWARD,
|
||||
_admin(handle_approve_reward),
|
||||
_parent(handle_approve_reward),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("claim_id"): cv.string,
|
||||
@@ -1210,7 +1246,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_POINTS,
|
||||
_admin(handle_add_points),
|
||||
_parent(handle_add_points),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
@@ -1368,7 +1404,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SKIP_CHORE,
|
||||
_admin(handle_skip_chore),
|
||||
_parent(handle_skip_chore),
|
||||
schema=vol.Schema({vol.Required(ATTR_CHORE_ID): cv.string}),
|
||||
)
|
||||
|
||||
@@ -1458,7 +1494,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"award_badge_manually",
|
||||
_admin(handle_award_badge_manually),
|
||||
_parent(handle_award_badge_manually),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "4.4.4"
|
||||
"version": "4.5.0"
|
||||
}
|
||||
|
||||
@@ -780,6 +780,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
|
||||
"points_icon": data.get("points_icon", "mdi:star"),
|
||||
# Global default card-design style; cards read this when no per-card override (#design).
|
||||
"card_design": settings.get("card_design", "classic"),
|
||||
# Non-admin parent role (#661): cards unlock parent controls for these HA users.
|
||||
"parent_user_ids": self.coordinator.storage.get_parent_user_ids(),
|
||||
"children": _build_children_summary(self.coordinator, common),
|
||||
"time_boundaries": time_boundaries,
|
||||
"time_periods": time_periods,
|
||||
|
||||
@@ -766,6 +766,21 @@ class TaskMateStorage:
|
||||
def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
|
||||
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
|
||||
|
||||
def get_parent_user_ids(self) -> list[str]:
|
||||
"""HA user IDs granted the non-admin TaskMate parent role (#661)."""
|
||||
raw = (self._data.get("settings", {}) or {}).get("parent_user_ids", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [x for x in raw if isinstance(x, str) and x]
|
||||
|
||||
def set_parent_user_ids(self, ids: list[str]) -> None:
|
||||
"""Replace the parent role list (deduped, order-preserving, strings only)."""
|
||||
seen: list[str] = []
|
||||
for x in ids or []:
|
||||
if isinstance(x, str) and x and x not in seen:
|
||||
seen.append(x)
|
||||
self._data.setdefault("settings", {})["parent_user_ids"] = seen
|
||||
|
||||
# --- mandatory reminder escalation (FEAT-6) ---
|
||||
def get_escalation_reminder_minutes(self) -> int:
|
||||
"""Minutes after a mandatory miss before the child reminder escalates."""
|
||||
|
||||
@@ -1311,6 +1311,7 @@ _UPDATE_SETTINGS_SCHEMA = {
|
||||
vol.Optional("time_night_end"): vol.Match(r"^\d{2}:\d{2}$"),
|
||||
vol.Optional("time_periods"): list,
|
||||
vol.Optional("vacation_periods"): list,
|
||||
vol.Optional("parent_user_ids"): [str],
|
||||
}
|
||||
|
||||
|
||||
@@ -1334,8 +1335,13 @@ async def _ws_update_settings(hass, connection, msg, coordinator):
|
||||
return
|
||||
storage.set_setting("vacation_periods", vacations)
|
||||
changed.append("vacation_periods")
|
||||
if "parent_user_ids" in msg:
|
||||
# Non-admin parent role (#661). Admin-gated write (this whole handler is
|
||||
# @_admin_only) so a parent can never grant/escalate the role.
|
||||
storage.set_parent_user_ids(list(msg["parent_user_ids"]))
|
||||
changed.append("parent_user_ids")
|
||||
for k, v in msg.items():
|
||||
if k in {"id", "type", "time_periods", "vacation_periods"}:
|
||||
if k in {"id", "type", "time_periods", "vacation_periods", "parent_user_ids"}:
|
||||
continue
|
||||
if k == "points_name":
|
||||
storage.set_points_name(v.strip())
|
||||
|
||||
@@ -777,6 +777,9 @@
|
||||
"panel.settings_chore_rotation_title": "Aufgabenrotation",
|
||||
"panel.settings_currency_hint": "Wie Punkte benannt und in der App angezeigt werden.",
|
||||
"panel.settings_currency_title": "Währung",
|
||||
"panel.settings_parents_title": "Eltern (ohne Admin-Rechte)",
|
||||
"panel.settings_parents_hint": "Geben Sie einem Haushaltsmitglied ohne Admin-Rechte die tägliche Kontrolle — Aufgaben bestätigen, Punkte anpassen, Belohnungen freigeben — ohne Home-Assistant-Administratorrechte. Dieses Admin-Panel können sie weiterhin nicht öffnen und die Konfiguration nicht ändern.",
|
||||
"panel.settings_parents_empty": "Keine Home-Assistant-Benutzer ohne Admin-Rechte gefunden. Erstellen Sie zuerst einen normalen HA-Benutzer und wählen Sie ihn dann hier aus.",
|
||||
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Abend",
|
||||
"panel.settings_history_title": "Verlauf & Serien",
|
||||
|
||||
@@ -815,6 +815,9 @@
|
||||
"panel.settings_chore_rotation_title": "Chore Rotation",
|
||||
"panel.settings_currency_hint": "How points are named and shown across the app.",
|
||||
"panel.settings_currency_title": "Currency",
|
||||
"panel.settings_parents_title": "Parents (no admin rights)",
|
||||
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
|
||||
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
|
||||
"panel.settings_evening_hint": "Default 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Evening",
|
||||
"panel.settings_history_title": "History & streaks",
|
||||
|
||||
@@ -815,6 +815,9 @@
|
||||
"panel.settings_chore_rotation_title": "Chore Rotation",
|
||||
"panel.settings_currency_hint": "How points are named and shown across the app.",
|
||||
"panel.settings_currency_title": "Currency",
|
||||
"panel.settings_parents_title": "Parents (no admin rights)",
|
||||
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
|
||||
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
|
||||
"panel.settings_evening_hint": "Default 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Evening",
|
||||
"panel.settings_history_title": "History & streaks",
|
||||
|
||||
@@ -777,6 +777,9 @@
|
||||
"panel.settings_chore_rotation_title": "Rotation des tâches",
|
||||
"panel.settings_currency_hint": "Comment les points sont nommés et affichés dans l'application.",
|
||||
"panel.settings_currency_title": "Monnaie",
|
||||
"panel.settings_parents_title": "Parents (sans droits admin)",
|
||||
"panel.settings_parents_hint": "Donnez à un membre du foyer non-administrateur le contrôle quotidien — valider les tâches, ajuster les points, confirmer les récompenses — sans droits d’administrateur Home Assistant. Il ne pourra toujours pas ouvrir ce panneau d’administration ni modifier la configuration.",
|
||||
"panel.settings_parents_empty": "Aucun utilisateur Home Assistant non-administrateur trouvé. Créez d’abord un utilisateur HA standard, puis cochez-le ici.",
|
||||
"panel.settings_evening_hint": "Par défaut 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Soir",
|
||||
"panel.settings_history_title": "Historique et séries",
|
||||
|
||||
@@ -777,6 +777,9 @@
|
||||
"panel.settings_chore_rotation_title": "Oppgaverotasjon",
|
||||
"panel.settings_currency_hint": "Hvordan poeng navngis og vises i appen.",
|
||||
"panel.settings_currency_title": "Valuta",
|
||||
"panel.settings_parents_title": "Foreldre (uten adminrettigheter)",
|
||||
"panel.settings_parents_hint": "Gi et husstandsmedlem uten adminrettigheter daglig kontroll — godkjenne oppgaver, justere poeng, bekrefte belønninger — uten Home Assistant-adminrettigheter. De kan fortsatt ikke åpne dette adminpanelet eller endre konfigurasjonen.",
|
||||
"panel.settings_parents_empty": "Fant ingen Home Assistant-brukere uten adminrettigheter. Opprett en vanlig HA-bruker først, og huk deretter av her.",
|
||||
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Kveld",
|
||||
"panel.settings_history_title": "Historikk og serier",
|
||||
|
||||
@@ -777,6 +777,9 @@
|
||||
"panel.settings_chore_rotation_title": "Oppgåverotasjon",
|
||||
"panel.settings_currency_hint": "Korleis poeng vert namngjeve og viste i appen.",
|
||||
"panel.settings_currency_title": "Valuta",
|
||||
"panel.settings_parents_title": "Foreldre (utan adminrettar)",
|
||||
"panel.settings_parents_hint": "Gi eit husstandsmedlem utan adminrettar dagleg kontroll — godkjenne oppgåver, justere poeng, stadfeste løningar — utan Home Assistant-adminrettar. Dei kan framleis ikkje opne dette adminpanelet eller endre konfigurasjonen.",
|
||||
"panel.settings_parents_empty": "Fann ingen Home Assistant-brukarar utan adminrettar. Opprett ein vanleg HA-brukar først, og hak deretter av her.",
|
||||
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Kveld",
|
||||
"panel.settings_history_title": "Historikk og rekkjer",
|
||||
|
||||
@@ -777,6 +777,9 @@
|
||||
"panel.settings_chore_rotation_title": "Rotação de Tarefas",
|
||||
"panel.settings_currency_hint": "Como os pontos são nomeados e exibidos no app.",
|
||||
"panel.settings_currency_title": "Moeda",
|
||||
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
|
||||
"panel.settings_parents_hint": "Dê a um membro da família sem permissões de administrador o controle do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Ele ainda não poderá abrir este painel de administração nem alterar a configuração.",
|
||||
"panel.settings_parents_empty": "Nenhum usuário do Home Assistant sem permissões de administrador foi encontrado. Crie primeiro um usuário HA padrão e depois marque-o aqui.",
|
||||
"panel.settings_evening_hint": "Padrão 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Noite",
|
||||
"panel.settings_history_title": "Histórico e sequências",
|
||||
|
||||
@@ -782,6 +782,9 @@
|
||||
"panel.settings_chore_rotation_title": "Rotação de Tarefas",
|
||||
"panel.settings_currency_hint": "Como os pontos são nomeados e apresentados na aplicação.",
|
||||
"panel.settings_currency_title": "Moeda",
|
||||
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
|
||||
"panel.settings_parents_hint": "Dê a um membro do agregado sem permissões de administrador o controlo do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Continuará sem poder abrir este painel de administração nem alterar a configuração.",
|
||||
"panel.settings_parents_empty": "Não foram encontrados utilizadores do Home Assistant sem permissões de administrador. Crie primeiro um utilizador HA normal e depois selecione-o aqui.",
|
||||
"panel.settings_evening_hint": "Predefinição 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Noite",
|
||||
"panel.settings_history_title": "Histórico e sequências",
|
||||
|
||||
@@ -66,6 +66,35 @@
|
||||
|
||||
window.__taskmate_attrs = resolveAttrs;
|
||||
|
||||
/**
|
||||
* Non-admin parent role (#661). True for Home Assistant admins and for users
|
||||
* the admin has designated as TaskMate parents (published as the
|
||||
* parent_user_ids attribute on sensor.taskmate_overview). Parents get the
|
||||
* day-to-day controls (approve/reject, complete-on-behalf, undo) without HA
|
||||
* admin rights. Admin remains the only tier that can open the admin panel or
|
||||
* change configuration.
|
||||
*/
|
||||
function isTaskmateParent(hass) {
|
||||
if (!hass || !hass.user) return false;
|
||||
if (hass.user.is_admin) return true;
|
||||
let ids;
|
||||
const ov = hass.states && hass.states["sensor.taskmate_overview"];
|
||||
if (ov && ov.attributes && Array.isArray(ov.attributes.parent_user_ids)) {
|
||||
ids = ov.attributes.parent_user_ids;
|
||||
} else if (hass.states) {
|
||||
// Overview sensor renamed: find whichever taskmate sensor carries the list.
|
||||
for (const st of Object.values(hass.states)) {
|
||||
if (st && st.attributes && Array.isArray(st.attributes.parent_user_ids)) {
|
||||
ids = st.attributes.parent_user_ids;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.isArray(ids) && ids.includes(hass.user.id);
|
||||
}
|
||||
|
||||
window.__taskmate_is_parent = isTaskmateParent;
|
||||
|
||||
/**
|
||||
* Event-driven update guard for LitElement cards.
|
||||
*
|
||||
|
||||
@@ -3385,8 +3385,8 @@ class TaskMateChildCard extends LitElement {
|
||||
if (this._loading[bonusKey]) return;
|
||||
|
||||
// Undoing removes already-awarded points, so it is parent-only — short-circuit
|
||||
// for non-admins with one friendly message instead of a doomed service call.
|
||||
if (this.hass.user && !this.hass.user.is_admin) {
|
||||
// for non-parents with one friendly message instead of a doomed service call.
|
||||
if (!window.__taskmate_is_parent(this.hass)) {
|
||||
this._notifyUndo(this._t("child.undo_not_allowed"));
|
||||
return;
|
||||
}
|
||||
@@ -3839,11 +3839,11 @@ class TaskMateChildCard extends LitElement {
|
||||
}
|
||||
|
||||
// Undoing a completion removes already-awarded points, so it is parent-only
|
||||
// (this mirrors the admin gate on the reject_chore service). For a non-admin
|
||||
// (this mirrors the parent gate on the reject_chore service). For a non-parent
|
||||
// user the call always fails with a raw "Unauthorized" — HA shows its own
|
||||
// snackbar and we used to add an error notification on top. Short-circuit
|
||||
// with one clear, friendly message and never fire the doomed call.
|
||||
if (this.hass.user && !this.hass.user.is_admin) {
|
||||
if (!window.__taskmate_is_parent(this.hass)) {
|
||||
this._notifyUndo(this._t("child.undo_not_allowed"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -524,7 +524,7 @@ class TaskMateOverviewCard extends LitElement {
|
||||
pendingApprovals = (attrs.chore_completions || completions.filter(c => !c.approved)).length;
|
||||
}
|
||||
|
||||
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
|
||||
const isParent = window.__taskmate_is_parent(this.hass);
|
||||
|
||||
// Aggregate today's progress across all children
|
||||
let doneTotal = 0;
|
||||
@@ -555,9 +555,9 @@ class TaskMateOverviewCard extends LitElement {
|
||||
<div class="tmd-bd">
|
||||
${pendingApprovals > 0 ? this._ovAlert(design, pendingApprovals) : ""}
|
||||
<div class="ov-kids">
|
||||
${kids.map((k) => this._ovKid(design, k, isAdmin, pointsIcon))}
|
||||
${kids.map((k) => this._ovKid(design, k, isParent, pointsIcon))}
|
||||
</div>
|
||||
${isAdmin ? kids.filter(k => this._expanded[k.child.id]).map(k => this._ovBehalf(k, pointsIcon)) : ""}
|
||||
${isParent ? kids.filter(k => this._expanded[k.child.id]).map(k => this._ovBehalf(k, pointsIcon)) : ""}
|
||||
${this._ovToday(design, doneTotal, choreTotal, overallPct)}
|
||||
</div>
|
||||
</ha-card>`;
|
||||
@@ -630,11 +630,11 @@ class TaskMateOverviewCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_ovKid(design, k, isAdmin, pointsIcon) {
|
||||
_ovKid(design, k, isParent, pointsIcon) {
|
||||
const cls = design === "console" ? "cn" : design === "cleanpro" ? "cp" : "";
|
||||
const nameUpper = design === "console" ? `${k.child.name.toUpperCase()} · ${k.pct}%` : k.child.name;
|
||||
const isOpen = !!this._expanded[k.child.id];
|
||||
const expandable = isAdmin;
|
||||
const expandable = isParent;
|
||||
return html`
|
||||
<div class="ov-kid ${cls} ${expandable ? 'tm-clickable' : ''}" style="--ac:${k.tone}"
|
||||
@click="${expandable ? () => { this._expanded = { ...this._expanded, [k.child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
@@ -737,7 +737,7 @@ class TaskMateOverviewCard extends LitElement {
|
||||
// Pending approvals for this child
|
||||
const childPending = childCompletions.filter(c => !c.approved).length;
|
||||
|
||||
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
|
||||
const isParent = window.__taskmate_is_parent(this.hass);
|
||||
const outstanding = childChores.filter(c => {
|
||||
const doneToday = completions.filter(
|
||||
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
|
||||
@@ -751,8 +751,8 @@ class TaskMateOverviewCard extends LitElement {
|
||||
<div class="child-avatar">
|
||||
<ha-icon icon="${avatar}"></ha-icon>
|
||||
</div>
|
||||
<div class="child-main ${isAdmin ? 'tm-expandable' : ''}"
|
||||
@click="${isAdmin ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
<div class="child-main ${isParent ? 'tm-expandable' : ''}"
|
||||
@click="${isParent ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
<div class="child-name-row">
|
||||
<span class="child-name">${child.name}</span>
|
||||
<div style="display:flex;gap:5px;align-items:center;flex-shrink:0;">
|
||||
@@ -787,7 +787,7 @@ class TaskMateOverviewCard extends LitElement {
|
||||
` : html`
|
||||
<div style="font-size:0.8rem;color:var(--secondary-text-color);opacity:0.7;">${this._t('common.no_chores_today')}</div>
|
||||
`}
|
||||
${isAdmin && isOpen ? html`
|
||||
${isParent && isOpen ? html`
|
||||
<div class="tm-outstanding" @click="${(e) => e.stopPropagation()}">
|
||||
<div class="tm-outstanding-hdr">${this._t('common.complete_on_behalf_heading')}</div>
|
||||
${outstanding.length === 0 ? html`
|
||||
|
||||
@@ -412,7 +412,12 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (!t) return;
|
||||
const act = t.dataset.act;
|
||||
|
||||
if (act === "tab") { this._activeTab = t.dataset.tab; this._filter = ""; this._render(); return; }
|
||||
if (act === "tab") {
|
||||
this._activeTab = t.dataset.tab; this._filter = ""; this._render();
|
||||
// Settings tab lists HA users for the parent-role picker (#661); load then repaint.
|
||||
if (this._activeTab === "settings") this._ensureHaUsers().then(() => this._render());
|
||||
return;
|
||||
}
|
||||
if (act === "close-dialog") { this._closeDialog(); return; }
|
||||
if (act === "clear-field") { this._setEntityField(t.dataset.field, ""); this._render(); return; }
|
||||
if (act === "pick-entity") { this._setEntityField(t.dataset.field, t.dataset.value); this._closeEntityDropdown(); this._render(); return; }
|
||||
@@ -1899,6 +1904,13 @@ class TaskMatePanel extends HTMLElement {
|
||||
root.querySelectorAll("ha-icon-picker[data-setting]").forEach(el => {
|
||||
payload[el.dataset.setting] = el.value || "";
|
||||
});
|
||||
// Non-admin parent role (#661): collect the ticked HA users.
|
||||
const parentBoxes = root.querySelectorAll("input[type=checkbox][data-parent-user]");
|
||||
if (parentBoxes.length) {
|
||||
payload.parent_user_ids = Array.from(parentBoxes)
|
||||
.filter(el => el.checked)
|
||||
.map(el => el.dataset.parentUser);
|
||||
}
|
||||
if (this._settingsEntityDraft) {
|
||||
Object.assign(payload, this._settingsEntityDraft);
|
||||
}
|
||||
@@ -3721,6 +3733,21 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tm-section">
|
||||
<div class="tm-section-head"><div>
|
||||
<h3>${this._t("panel.settings_parents_title")}</h3>
|
||||
<p class="tm-meta">${this._t("panel.settings_parents_hint")}</p>
|
||||
</div></div>
|
||||
<div class="tm-section-body">
|
||||
${(this._haUsers || []).filter(u => !u.is_admin).map(u => `
|
||||
<label class="tm-setting-row" style="cursor:pointer">
|
||||
<div class="tm-setting-label">${this._esc(u.name)}</div>
|
||||
<input type="checkbox" data-parent-user="${this._esc(u.id)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}>
|
||||
</label>`).join("")
|
||||
|| `<p class="tm-meta">${this._t("panel.settings_parents_empty")}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tm-section">
|
||||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_history_title")}</h3></div></div>
|
||||
<div class="tm-section-body">
|
||||
|
||||
@@ -763,7 +763,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
if (design === "cleanpro") {
|
||||
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
|
||||
const isParent = window.__taskmate_is_parent(this.hass);
|
||||
return html`
|
||||
<div class="row pd-ov-cp" style="--ac:${tone}">
|
||||
${this._av(child.name, child.avatar, tone, 36)}
|
||||
@@ -774,7 +774,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
</div>
|
||||
<div class="bar" style="margin-top:7px;height:8px"><i style="width:${pct}%"></i></div>
|
||||
</div>
|
||||
${isAdmin ? html`<button class="btn ghost round sm"
|
||||
${isParent ? html`<button class="btn ghost round sm"
|
||||
title="${this._t('dashboard.tab_points')}"
|
||||
@click="${() => this._switchTab('points')}">+</button>` : ""}
|
||||
</div>
|
||||
@@ -982,7 +982,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
const isComplete = total > 0 && approved >= total;
|
||||
const cls = isComplete ? "complete" : pct > 0 ? "partial" : "none";
|
||||
|
||||
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
|
||||
const isParent = window.__taskmate_is_parent(this.hass);
|
||||
const outstanding = childChores.filter(c => {
|
||||
const doneToday = completions.filter(
|
||||
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
|
||||
@@ -992,12 +992,12 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
const isOpen = !!this._expanded[child.id];
|
||||
|
||||
return html`
|
||||
<div class="child-tile ${isAdmin ? 'tm-expandable' : ''}">
|
||||
<div class="child-tile ${isParent ? 'tm-expandable' : ''}">
|
||||
<div class="child-avatar">
|
||||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||||
</div>
|
||||
<div class="child-tile-main"
|
||||
@click="${isAdmin ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
@click="${isParent ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
<div class="child-tile-header">
|
||||
<span class="child-tile-name">${child.name}</span>
|
||||
<span class="points-pill">
|
||||
@@ -1011,7 +1011,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
</div>
|
||||
<span class="progress-label">${approved}/${total}</span>
|
||||
</div>
|
||||
${isAdmin && isOpen ? html`
|
||||
${isParent && isOpen ? html`
|
||||
<div class="tm-outstanding" @click="${(e) => e.stopPropagation()}">
|
||||
<div class="tm-outstanding-hdr">${this._t('common.complete_on_behalf_heading')}</div>
|
||||
${outstanding.length === 0 ? html`
|
||||
|
||||
Reference in New Issue
Block a user