393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -10,8 +10,11 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { confirmAction } from "../helpers/confirm";
import type { HomeAssistant } from "../types";
interface GroupEntry {
@@ -36,6 +39,8 @@ export class MaintenanceGroupsSectionCard extends LitElement {
@state() private _newName = "";
@state() private _editingId: string | null = null;
@state() private _editingName = "";
/** Operator-write delegation — group CRUD is write tier (canWrite). */
@state() private _access: WriteAccess = NO_DELEGATION;
private _hasInitiallyLoaded = false;
@@ -51,8 +56,12 @@ export class MaintenanceGroupsSectionCard extends LitElement {
return langOf(this.hass);
}
private get _isAdmin(): boolean {
return (this.hass?.user?.is_admin ?? true) as boolean;
/** Group create / rename / delete are WRITE tier on the server: admins,
* plus allowlisted operators with delegation on — the panel's rule
* (helpers/permissions). `is_admin` alone hid the controls from a
* delegated operator (DRY audit 2026-09-26). */
private get _canWrite(): boolean {
return canWrite(this.hass?.user, this._access);
}
updated(changedProps: Map<string, unknown>): void {
@@ -60,40 +69,39 @@ export class MaintenanceGroupsSectionCard extends LitElement {
if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) {
this._hasInitiallyLoaded = true;
void this._load();
void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; });
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<GroupsResp>({
type: "maintenance_supporter/groups",
});
this._groups = r.groups || {};
this._loaded = true;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
const r = await runWs<GroupsResp>(this, { type: "maintenance_supporter/groups" }, {
onError: (m) => { this._error = m; },
});
if (r === undefined) return;
this._groups = r?.groups || {};
this._loaded = true;
}
/** One group mutation — helpers/ws-run runWs with the busy flag, the
* error line and the list reload (`onDone` runs first). */
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
this._error = "";
await runWs(this, payload, {
busy: (b) => { this._busy = b; },
reload: async () => {
onDone?.();
await this._load();
},
onError: (m) => { this._error = m; },
});
}
private async _addGroup(): Promise<void> {
if (!this._isAdmin) return;
if (!this._canWrite) return;
const name = this._newName.trim();
if (!name) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/create",
name,
});
this._newName = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
await this._act({ type: "maintenance_supporter/group/create", name }, () => { this._newName = ""; });
}
private _startEdit(id: string): void {
@@ -102,43 +110,25 @@ export class MaintenanceGroupsSectionCard extends LitElement {
}
private async _saveEdit(): Promise<void> {
if (!this._isAdmin || !this._editingId) return;
if (!this._canWrite || !this._editingId) return;
const name = this._editingName.trim();
if (!name) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/update",
group_id: this._editingId,
name,
});
await this._act({ type: "maintenance_supporter/group/update", group_id: this._editingId, name }, () => {
this._editingId = null;
this._editingName = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
});
}
private async _deleteGroup(id: string, name: string): Promise<void> {
if (!this._isAdmin) return;
const confirmText = t("group_delete_confirm", this._lang).replace("{name}", name);
if (!window.confirm(confirmText)) return;
this._busy = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/delete",
group_id: id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
if (!this._canWrite) return;
const ok = await confirmAction(this.hass, {
title: t("delete", this._lang),
message: t("delete_group_confirm", this._lang).replace("{name}", name),
confirmText: t("delete", this._lang),
danger: true,
});
if (!ok) return;
await this._act({ type: "maintenance_supporter/group/delete", group_id: id });
}
private _onDeepLink(): void {
@@ -210,7 +200,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
: html`
<span class="group-name">${g.name || "Unnamed"}</span>
<span class="task-count">${taskCount}</span>
${this._isAdmin
${this._canWrite
? html`
<button class="icon-btn"
title="${t("edit", L)}"
@@ -233,7 +223,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
</div>
`}
${this._isAdmin
${this._canWrite
? html`
<div class="add-row">
<input type="text"