@matter-server/dashboard 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/dialogs/acl/acl-actions.d.ts +12 -0
- package/dist/esm/components/dialogs/acl/acl-actions.d.ts.map +1 -0
- package/dist/esm/components/dialogs/acl/acl-actions.js +56 -0
- package/dist/esm/components/dialogs/acl/acl-actions.js.map +6 -0
- package/dist/esm/components/dialogs/acl/model.d.ts +2 -2
- package/dist/esm/components/dialogs/acl/model.d.ts.map +1 -1
- package/dist/esm/components/dialogs/acl/model.js +11 -15
- package/dist/esm/components/dialogs/acl/model.js.map +1 -1
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts +43 -0
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts.map +1 -0
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js +353 -0
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js.map +6 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts +8 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts.map +1 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js +15 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js.map +6 -0
- package/dist/esm/components/dialogs/binding/binding-actions.d.ts +17 -0
- package/dist/esm/components/dialogs/binding/binding-actions.d.ts.map +1 -0
- package/dist/esm/components/dialogs/binding/binding-actions.js +135 -0
- package/dist/esm/components/dialogs/binding/binding-actions.js.map +6 -0
- package/dist/esm/components/dialogs/binding/model.d.ts +1 -1
- package/dist/esm/components/dialogs/binding/model.d.ts.map +1 -1
- package/dist/esm/components/dialogs/binding/model.js +8 -3
- package/dist/esm/components/dialogs/binding/model.js.map +1 -1
- package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts +16 -24
- package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
- package/dist/esm/components/dialogs/binding/node-binding-dialog.js +210 -332
- package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts +37 -0
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts.map +1 -0
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js +387 -0
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js.map +6 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts +35 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts.map +1 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.js +254 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.js.map +6 -0
- package/dist/esm/pages/cluster-commands/index.d.ts +2 -0
- package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
- package/dist/esm/pages/cluster-commands/index.js +2 -0
- package/dist/esm/pages/cluster-commands/index.js.map +1 -1
- package/dist/esm/pages/components/node-details.d.ts +0 -1
- package/dist/esm/pages/components/node-details.d.ts.map +1 -1
- package/dist/esm/pages/components/node-details.js +2 -18
- package/dist/esm/pages/components/node-details.js.map +1 -1
- package/dist/esm/pages/components/server-details.js +3 -3
- package/dist/esm/pages/components/server-details.js.map +1 -1
- package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-cluster-view.js +2 -1
- package/dist/esm/pages/matter-cluster-view.js.map +1 -1
- package/dist/esm/pages/matter-endpoint-view.d.ts +3 -3
- package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-endpoint-view.js +13 -10
- package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
- package/dist/esm/pages/matter-node-view.js +2 -2
- package/dist/esm/pages/matter-node-view.js.map +1 -1
- package/dist/esm/pages/network/network-utils.d.ts +1 -5
- package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
- package/dist/esm/pages/network/network-utils.js +1 -11
- package/dist/esm/pages/network/network-utils.js.map +1 -1
- package/dist/esm/util/access-control.d.ts +54 -0
- package/dist/esm/util/access-control.d.ts.map +1 -0
- package/dist/esm/util/access-control.js +100 -0
- package/dist/esm/util/access-control.js.map +6 -0
- package/dist/esm/util/binding.d.ts +54 -0
- package/dist/esm/util/binding.d.ts.map +1 -0
- package/dist/esm/util/binding.js +113 -0
- package/dist/esm/util/binding.js.map +6 -0
- package/dist/esm/util/endpoints.d.ts +9 -0
- package/dist/esm/util/endpoints.d.ts.map +1 -0
- package/dist/esm/util/endpoints.js +18 -0
- package/dist/esm/util/endpoints.js.map +6 -0
- package/dist/esm/util/node-name.d.ts +12 -0
- package/dist/esm/util/node-name.d.ts.map +1 -0
- package/dist/esm/util/node-name.js +15 -0
- package/dist/esm/util/node-name.js.map +6 -0
- package/dist/web/js/{attribute-write-dialog-W7xpCE2E.js → attribute-write-dialog-CqqdRniU.js} +1 -1
- package/dist/web/js/{command-invoke-dialog-BAqAAdJw.js → command-invoke-dialog-BuvBOrdC.js} +1 -1
- package/dist/web/js/{commission-node-dialog-BTzCGgdy.js → commission-node-dialog-nVZp3go0.js} +5 -5
- package/dist/web/js/{commission-node-existing-B2M2hyDh.js → commission-node-existing-Cx3Ahk2t.js} +2 -2
- package/dist/web/js/{commission-node-thread-djdz2dXW.js → commission-node-thread-CI8mWWPs.js} +2 -2
- package/dist/web/js/{commission-node-wifi-DxAYNS1A.js → commission-node-wifi-lUX4LK2R.js} +2 -2
- package/dist/web/js/{dialog-box-tHvPVxDN.js → dialog-box-bAdbnf-T.js} +1 -1
- package/dist/web/js/{fire_event-BleYfTLc.js → fire_event-tWhqPfdz.js} +1 -1
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/{matter-dashboard-app-CVi_GDky.js → matter-dashboard-app-CONA_608.js} +1485 -390
- package/dist/web/js/node-acl-add-dialog-DlR-sF-b.js +320 -0
- package/dist/web/js/node-binding-dialog-DhnX_86M.js +267 -0
- package/dist/web/js/{node-label-dialog-DVZSjsXU.js → node-label-dialog-T3nPG-Qy.js} +1 -1
- package/dist/web/js/{settings-dialog-BG5MgZcO.js → settings-dialog-DrHzJtsi.js} +1 -1
- package/package.json +4 -4
- package/src/components/dialogs/acl/acl-actions.ts +71 -0
- package/src/components/dialogs/acl/model.ts +18 -17
- package/src/components/dialogs/acl/node-acl-add-dialog.ts +350 -0
- package/src/components/dialogs/acl/show-node-acl-add-dialog.ts +14 -0
- package/src/components/dialogs/binding/binding-actions.ts +201 -0
- package/src/components/dialogs/binding/model.ts +11 -4
- package/src/components/dialogs/binding/node-binding-dialog.ts +221 -399
- package/src/pages/cluster-commands/clusters/access-control-commands.ts +407 -0
- package/src/pages/cluster-commands/clusters/binding-commands.ts +273 -0
- package/src/pages/cluster-commands/index.ts +2 -0
- package/src/pages/components/node-details.ts +2 -21
- package/src/pages/components/server-details.ts +3 -3
- package/src/pages/matter-cluster-view.ts +4 -1
- package/src/pages/matter-endpoint-view.ts +16 -10
- package/src/pages/matter-node-view.ts +2 -2
- package/src/pages/network/network-utils.ts +1 -18
- package/src/util/access-control.ts +135 -0
- package/src/util/binding.ts +182 -0
- package/src/util/endpoints.ts +17 -0
- package/src/util/node-name.ts +18 -0
- package/dist/web/js/node-binding-dialog-B9IdqHrZ.js +0 -624
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025-2026 Open Home Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import "@material/web/button/outlined-button";
|
|
8
|
+
import { mdiAlert, mdiLink, mdiLock, mdiTrashCan } from "@mdi/js";
|
|
9
|
+
import { css, html, nothing, type CSSResultGroup, type TemplateResult } from "lit";
|
|
10
|
+
import { customElement, state } from "lit/decorators.js";
|
|
11
|
+
import "../../../components/ha-svg-icon.js";
|
|
12
|
+
import { clusters } from "../../../client/models/descriptions.js";
|
|
13
|
+
import { showAlertDialog, showPromptDialog } from "../../../components/dialog-box/show-dialog-box.js";
|
|
14
|
+
import { deleteAclEntry, downgradeToOperate } from "../../../components/dialogs/acl/acl-actions.js";
|
|
15
|
+
import type { AccessControlEntryStruct } from "../../../components/dialogs/acl/model.js";
|
|
16
|
+
import { showNodeAclAddDialog } from "../../../components/dialogs/acl/show-node-acl-add-dialog.js";
|
|
17
|
+
import {
|
|
18
|
+
AUTH_MODE_NAMES,
|
|
19
|
+
AuthMode,
|
|
20
|
+
PRIVILEGE_NAMES,
|
|
21
|
+
Privilege,
|
|
22
|
+
aclCapacity,
|
|
23
|
+
aclEntryKey,
|
|
24
|
+
entriesForFabric,
|
|
25
|
+
isProtectedAdmin,
|
|
26
|
+
isWholeNode,
|
|
27
|
+
nodeFabricIndex,
|
|
28
|
+
nodeIdKey,
|
|
29
|
+
readAclEntries,
|
|
30
|
+
} from "../../../util/access-control.js";
|
|
31
|
+
import { handleAsync } from "../../../util/async-handler.js";
|
|
32
|
+
import { detectBindingRelationship, type RelationshipResult } from "../../../util/binding.js";
|
|
33
|
+
import { getDeviceName } from "../../../util/node-name.js";
|
|
34
|
+
import { BaseClusterCommands } from "../base-cluster-commands.js";
|
|
35
|
+
import { registerClusterCommands } from "../registry.js";
|
|
36
|
+
|
|
37
|
+
const CLUSTER_ID = 31;
|
|
38
|
+
|
|
39
|
+
@customElement("access-control-cluster-commands")
|
|
40
|
+
class AccessControlClusterCommands extends BaseClusterCommands {
|
|
41
|
+
private _unsubscribe?: () => void;
|
|
42
|
+
private _loadedKey = "";
|
|
43
|
+
@state() private _busy = false;
|
|
44
|
+
|
|
45
|
+
override updated(changed: Map<string, unknown>) {
|
|
46
|
+
super.updated(changed);
|
|
47
|
+
if (changed.has("client") && this.client && !this._unsubscribe) {
|
|
48
|
+
this._unsubscribe = this.client.addEventListener("nodes_changed", () => this.requestUpdate());
|
|
49
|
+
}
|
|
50
|
+
void this._ensureLoaded();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override disconnectedCallback() {
|
|
54
|
+
super.disconnectedCallback();
|
|
55
|
+
this._unsubscribe?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The acl attribute is fabric-scoped and may be absent from the cache until read. Load it on open. */
|
|
59
|
+
private async _ensureLoaded() {
|
|
60
|
+
if (!this.client || !this.node || !this.node.available) return;
|
|
61
|
+
const key = nodeIdKey(this.node.node_id);
|
|
62
|
+
if (this._loadedKey === key) return;
|
|
63
|
+
this._loadedKey = key;
|
|
64
|
+
try {
|
|
65
|
+
const res = await this.client.readAttribute(this.node.node_id, ["0/31/0", "0/62/5"]);
|
|
66
|
+
for (const [k, v] of Object.entries(res)) this.node.attributes[k] = v;
|
|
67
|
+
this.requestUpdate();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this._loadedKey = ""; // allow retry on the next update after a transient failure
|
|
70
|
+
console.error("Failed to load ACL", err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private get _controllerNodeId(): number | bigint | undefined {
|
|
75
|
+
return this.client.serverInfo?.controller_node_id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private _entries(): AccessControlEntryStruct[] {
|
|
79
|
+
return entriesForFabric(readAclEntries(this.node), nodeFabricIndex(this.node));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private _clusterName(id: number | undefined): string {
|
|
83
|
+
if (id == null) return "all clusters";
|
|
84
|
+
return `${clusters[id]?.label ?? "Cluster"} (0x${id.toString(16).padStart(2, "0").toUpperCase()})`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private _privilegeClass(p: number): string {
|
|
88
|
+
return `pv pv-${p}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async _delete(entry: AccessControlEntryStruct) {
|
|
92
|
+
const isAdmin = entry.privilege === Privilege.Administer && entry.authMode === AuthMode.Case;
|
|
93
|
+
const unverified = isAdmin && this._controllerNodeId === undefined;
|
|
94
|
+
const confirmed = await showPromptDialog({
|
|
95
|
+
title: "Delete ACL entry",
|
|
96
|
+
text: unverified
|
|
97
|
+
? "This is an Administer entry and the controller cannot verify whether it is its own. Deleting the wrong admin entry can lock the controller out of this device. Continue?"
|
|
98
|
+
: "Remove this access control entry? Devices relying on it will lose the granted access.",
|
|
99
|
+
confirmText: "Delete",
|
|
100
|
+
});
|
|
101
|
+
if (!confirmed) return;
|
|
102
|
+
this._busy = true;
|
|
103
|
+
try {
|
|
104
|
+
await deleteAclEntry(this.client, this.node.node_id, aclEntryKey(entry));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
await showAlertDialog({ title: "Delete failed", text: err instanceof Error ? err.message : String(err) });
|
|
107
|
+
} finally {
|
|
108
|
+
this._busy = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async _fix(keys: Set<string>) {
|
|
113
|
+
this._busy = true;
|
|
114
|
+
try {
|
|
115
|
+
await downgradeToOperate(this.client, this.node.node_id, keys);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
await showAlertDialog({ title: "Fix failed", text: err instanceof Error ? err.message : String(err) });
|
|
118
|
+
} finally {
|
|
119
|
+
this._busy = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async _openAdd() {
|
|
124
|
+
await showNodeAclAddDialog(this.node);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private _renderSubjects(entry: AccessControlEntryStruct): TemplateResult {
|
|
128
|
+
const subjects = entry.subjects ?? [];
|
|
129
|
+
if (entry.authMode === AuthMode.Case && subjects.length === 0) {
|
|
130
|
+
return html`<span class="mut">Any node on fabric</span>`;
|
|
131
|
+
}
|
|
132
|
+
if (entry.authMode === AuthMode.Group) {
|
|
133
|
+
return html`${subjects.map(s => html`<div class="ident">Group ${s.toString()}</div>`)}`;
|
|
134
|
+
}
|
|
135
|
+
return html`${subjects.map(s => {
|
|
136
|
+
const known = this.client.nodes[nodeIdKey(s)];
|
|
137
|
+
const protectedMe =
|
|
138
|
+
isProtectedAdmin(entry, this._controllerNodeId) && nodeIdKey(s) === nodeIdKey(this._controllerNodeId!);
|
|
139
|
+
return html`<div class="ident ${protectedMe ? "me" : ""}">
|
|
140
|
+
${protectedMe ? "This controller" : known ? getDeviceName(known) : "Unknown node"} ·
|
|
141
|
+
<span class="nid">${s.toString()}</span><span class="hex">0x${s.toString(16).toUpperCase()}</span>
|
|
142
|
+
</div>`;
|
|
143
|
+
})}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private _renderTargets(entry: AccessControlEntryStruct): TemplateResult {
|
|
147
|
+
if (isWholeNode(entry)) return html`<span class="mut">Whole node</span>`;
|
|
148
|
+
return html`${entry.targets!.map(t => {
|
|
149
|
+
if (t.cluster != null)
|
|
150
|
+
return html`<span class="chip ep">EP ${t.endpoint ?? "*"} · ${this._clusterName(t.cluster)}</span>`;
|
|
151
|
+
if (t.deviceType != null)
|
|
152
|
+
return html`<span class="chip ep">EP ${t.endpoint ?? "*"} · device type ${t.deviceType}</span>`;
|
|
153
|
+
return html`<span class="chip ep">EP ${t.endpoint ?? "*"}</span>`;
|
|
154
|
+
})}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private _renderRelationship(rel: RelationshipResult): TemplateResult {
|
|
158
|
+
if (rel.kind === "none") return html`<span class="mut">—</span>`;
|
|
159
|
+
const source = rel.sourceNodeId != null ? this.client.nodes[nodeIdKey(rel.sourceNodeId)] : undefined;
|
|
160
|
+
const label = source ? getDeviceName(source) : "node";
|
|
161
|
+
if (rel.kind === "overPrivileged") {
|
|
162
|
+
return html`<span class="chip bug"
|
|
163
|
+
><ha-svg-icon .path=${mdiAlert}></ha-svg-icon> over-privileged binding ACL</span
|
|
164
|
+
>`;
|
|
165
|
+
}
|
|
166
|
+
return html`<span class="chip link"
|
|
167
|
+
><ha-svg-icon .path=${mdiLink}></ha-svg-icon> backs binding · ${label} EP${rel.sourceEndpoint}</span
|
|
168
|
+
>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override render() {
|
|
172
|
+
if (!this.node || this.cluster !== CLUSTER_ID) return nothing;
|
|
173
|
+
const entries = this._entries();
|
|
174
|
+
const allNodes = Object.values(this.client.nodes);
|
|
175
|
+
const rels = entries.map(e => detectBindingRelationship(e, this.node.node_id, allNodes));
|
|
176
|
+
const capacity = aclCapacity(this.node);
|
|
177
|
+
const full = capacity.max > 0 && entries.length >= capacity.max;
|
|
178
|
+
|
|
179
|
+
const overPrivilegedKeys = new Set<string>();
|
|
180
|
+
entries.forEach((e, i) => {
|
|
181
|
+
if (rels[i].kind === "overPrivileged") overPrivilegedKeys.add(aclEntryKey(e));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return html`
|
|
185
|
+
<details class="command-panel">
|
|
186
|
+
<summary>Access Control — ACL Entries (${entries.length})</summary>
|
|
187
|
+
<div class="command-content">
|
|
188
|
+
${overPrivilegedKeys.size >= 2
|
|
189
|
+
? html`<div class="banner">
|
|
190
|
+
<span
|
|
191
|
+
>${overPrivilegedKeys.size} binding ACL entries grant Administer where Operate is
|
|
192
|
+
sufficient.</span
|
|
193
|
+
>
|
|
194
|
+
<md-outlined-button
|
|
195
|
+
?disabled=${this._busy || !this.node.available}
|
|
196
|
+
@click=${handleAsync(() => this._fix(overPrivilegedKeys))}
|
|
197
|
+
>Fix all → Operate</md-outlined-button
|
|
198
|
+
>
|
|
199
|
+
</div>`
|
|
200
|
+
: nothing}
|
|
201
|
+
<table class="acl">
|
|
202
|
+
<thead>
|
|
203
|
+
<tr>
|
|
204
|
+
<th>Privilege</th>
|
|
205
|
+
<th>Auth</th>
|
|
206
|
+
<th>Subjects</th>
|
|
207
|
+
<th>Targets</th>
|
|
208
|
+
<th>Relationship</th>
|
|
209
|
+
<th></th>
|
|
210
|
+
</tr>
|
|
211
|
+
</thead>
|
|
212
|
+
<tbody>
|
|
213
|
+
${entries.map((e, i) => this._row(e, rels[i]))}
|
|
214
|
+
</tbody>
|
|
215
|
+
</table>
|
|
216
|
+
<md-outlined-button
|
|
217
|
+
?disabled=${this._busy || full || !this.node.available}
|
|
218
|
+
title=${full ? "Access control list is full" : ""}
|
|
219
|
+
@click=${handleAsync(() => this._openAdd())}
|
|
220
|
+
>Add ACL entry</md-outlined-button
|
|
221
|
+
>
|
|
222
|
+
${full ? html`<span class="mut full-note">List full (${capacity.max} entries)</span>` : nothing}
|
|
223
|
+
</div>
|
|
224
|
+
</details>
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private _row(entry: AccessControlEntryStruct, rel: RelationshipResult): TemplateResult {
|
|
229
|
+
const protectedEntry = isProtectedAdmin(entry, this._controllerNodeId);
|
|
230
|
+
const overPrivileged = rel.kind === "overPrivileged";
|
|
231
|
+
return html`
|
|
232
|
+
<tr class=${overPrivileged ? "row-warn" : ""}>
|
|
233
|
+
<td>
|
|
234
|
+
<span class=${this._privilegeClass(entry.privilege)}
|
|
235
|
+
>${PRIVILEGE_NAMES[entry.privilege] ?? entry.privilege} · ${entry.privilege}</span
|
|
236
|
+
>
|
|
237
|
+
${overPrivileged
|
|
238
|
+
? html`<div>
|
|
239
|
+
<md-outlined-button
|
|
240
|
+
class="fix"
|
|
241
|
+
?disabled=${this._busy || !this.node.available}
|
|
242
|
+
@click=${handleAsync(() => this._fix(new Set([aclEntryKey(entry)])))}
|
|
243
|
+
>Fix → Operate</md-outlined-button
|
|
244
|
+
>
|
|
245
|
+
</div>`
|
|
246
|
+
: nothing}
|
|
247
|
+
</td>
|
|
248
|
+
<td>${AUTH_MODE_NAMES[entry.authMode] ?? entry.authMode}</td>
|
|
249
|
+
<td>${this._renderSubjects(entry)}</td>
|
|
250
|
+
<td>${this._renderTargets(entry)}</td>
|
|
251
|
+
<td>${this._renderRelationship(rel)}</td>
|
|
252
|
+
<td>
|
|
253
|
+
${protectedEntry
|
|
254
|
+
? html`<ha-svg-icon
|
|
255
|
+
class="lock"
|
|
256
|
+
.path=${mdiLock}
|
|
257
|
+
title="Your controller's administrator entry — deleting it would lock you out."
|
|
258
|
+
></ha-svg-icon>`
|
|
259
|
+
: html`<md-outlined-button
|
|
260
|
+
class="danger"
|
|
261
|
+
?disabled=${this._busy || !this.node.available}
|
|
262
|
+
@click=${handleAsync(() => this._delete(entry))}
|
|
263
|
+
>
|
|
264
|
+
<ha-svg-icon .path=${mdiTrashCan} slot="icon"></ha-svg-icon>delete
|
|
265
|
+
</md-outlined-button>`}
|
|
266
|
+
</td>
|
|
267
|
+
</tr>
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
static override styles: CSSResultGroup = [
|
|
272
|
+
BaseClusterCommands.styles,
|
|
273
|
+
css`
|
|
274
|
+
.acl {
|
|
275
|
+
width: 100%;
|
|
276
|
+
border-collapse: collapse;
|
|
277
|
+
margin-bottom: 12px;
|
|
278
|
+
}
|
|
279
|
+
.acl th {
|
|
280
|
+
text-align: left;
|
|
281
|
+
font-size: 11px;
|
|
282
|
+
text-transform: uppercase;
|
|
283
|
+
opacity: 0.6;
|
|
284
|
+
padding: 8px 10px;
|
|
285
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
286
|
+
}
|
|
287
|
+
.acl td {
|
|
288
|
+
padding: 10px;
|
|
289
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
290
|
+
vertical-align: middle;
|
|
291
|
+
}
|
|
292
|
+
.ident {
|
|
293
|
+
line-height: 1.4;
|
|
294
|
+
}
|
|
295
|
+
.ident.me {
|
|
296
|
+
color: var(--md-sys-color-primary);
|
|
297
|
+
font-weight: 600;
|
|
298
|
+
}
|
|
299
|
+
.ident .nid {
|
|
300
|
+
font-weight: 600;
|
|
301
|
+
}
|
|
302
|
+
.row-warn td {
|
|
303
|
+
background: var(--md-sys-color-error-container);
|
|
304
|
+
box-shadow: inset 3px 0 0 var(--md-sys-color-error);
|
|
305
|
+
}
|
|
306
|
+
.pv {
|
|
307
|
+
display: inline-block;
|
|
308
|
+
padding: 2px 8px;
|
|
309
|
+
border-radius: 999px;
|
|
310
|
+
font-size: 12px;
|
|
311
|
+
font-weight: 700;
|
|
312
|
+
}
|
|
313
|
+
.pv-5 {
|
|
314
|
+
background: var(--md-sys-color-error-container);
|
|
315
|
+
color: var(--md-sys-color-on-error-container);
|
|
316
|
+
}
|
|
317
|
+
.pv-4 {
|
|
318
|
+
background: var(--md-sys-color-tertiary-container);
|
|
319
|
+
color: var(--md-sys-color-on-tertiary-container);
|
|
320
|
+
}
|
|
321
|
+
.pv-3 {
|
|
322
|
+
background: var(--md-sys-color-primary-container);
|
|
323
|
+
color: var(--md-sys-color-on-primary-container);
|
|
324
|
+
}
|
|
325
|
+
.pv-1,
|
|
326
|
+
.pv-2 {
|
|
327
|
+
background: var(--md-sys-color-surface-container-highest);
|
|
328
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
329
|
+
}
|
|
330
|
+
.chip {
|
|
331
|
+
display: inline-flex;
|
|
332
|
+
align-items: center;
|
|
333
|
+
gap: 4px;
|
|
334
|
+
padding: 3px 8px;
|
|
335
|
+
border-radius: 6px;
|
|
336
|
+
margin: 2px 4px 2px 0;
|
|
337
|
+
font-size: inherit;
|
|
338
|
+
background: var(--md-sys-color-surface-container-high);
|
|
339
|
+
color: var(--md-sys-color-on-surface);
|
|
340
|
+
}
|
|
341
|
+
.chip ha-svg-icon {
|
|
342
|
+
--mdc-icon-size: 14px;
|
|
343
|
+
width: 14px;
|
|
344
|
+
height: 14px;
|
|
345
|
+
}
|
|
346
|
+
.chip.ep {
|
|
347
|
+
background: var(--md-sys-color-secondary-container);
|
|
348
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
349
|
+
}
|
|
350
|
+
.chip.me {
|
|
351
|
+
background: var(--md-sys-color-tertiary-container);
|
|
352
|
+
color: var(--md-sys-color-on-tertiary-container);
|
|
353
|
+
}
|
|
354
|
+
.chip.link {
|
|
355
|
+
background: var(--md-sys-color-primary-container);
|
|
356
|
+
color: var(--md-sys-color-on-primary-container);
|
|
357
|
+
}
|
|
358
|
+
.chip.bug {
|
|
359
|
+
background: var(--md-sys-color-error-container);
|
|
360
|
+
color: var(--md-sys-color-on-error-container);
|
|
361
|
+
}
|
|
362
|
+
.nid {
|
|
363
|
+
font-weight: 600;
|
|
364
|
+
}
|
|
365
|
+
.hex {
|
|
366
|
+
font-family: var(--monospace-font, monospace);
|
|
367
|
+
font-size: 10px;
|
|
368
|
+
opacity: 0.6;
|
|
369
|
+
margin-left: 4px;
|
|
370
|
+
}
|
|
371
|
+
.mut {
|
|
372
|
+
opacity: 0.6;
|
|
373
|
+
}
|
|
374
|
+
.full-note {
|
|
375
|
+
margin-left: 8px;
|
|
376
|
+
font-size: 12px;
|
|
377
|
+
}
|
|
378
|
+
.banner {
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
justify-content: space-between;
|
|
382
|
+
gap: 12px;
|
|
383
|
+
padding: 10px 12px;
|
|
384
|
+
margin-bottom: 12px;
|
|
385
|
+
border-radius: 8px;
|
|
386
|
+
background: var(--md-sys-color-error-container);
|
|
387
|
+
color: var(--md-sys-color-on-error-container);
|
|
388
|
+
}
|
|
389
|
+
md-outlined-button.danger {
|
|
390
|
+
--md-outlined-button-label-text-color: var(--md-sys-color-error);
|
|
391
|
+
--md-outlined-button-outline-color: var(--md-sys-color-error);
|
|
392
|
+
}
|
|
393
|
+
md-outlined-button.fix {
|
|
394
|
+
margin-top: 4px;
|
|
395
|
+
--md-outlined-button-container-height: 28px;
|
|
396
|
+
}
|
|
397
|
+
`,
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
registerClusterCommands(CLUSTER_ID, "access-control-cluster-commands");
|
|
402
|
+
|
|
403
|
+
declare global {
|
|
404
|
+
interface HTMLElementTagNameMap {
|
|
405
|
+
"access-control-cluster-commands": AccessControlClusterCommands;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025-2026 Open Home Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import "@material/web/button/outlined-button";
|
|
8
|
+
import { mdiTrashCan } from "@mdi/js";
|
|
9
|
+
import { css, html, nothing, type CSSResultGroup, type TemplateResult } from "lit";
|
|
10
|
+
import { customElement, state } from "lit/decorators.js";
|
|
11
|
+
import "../../../components/ha-svg-icon.js";
|
|
12
|
+
import { clusters } from "../../../client/models/descriptions.js";
|
|
13
|
+
import { showAlertDialog, showPromptDialog } from "../../../components/dialog-box/show-dialog-box.js";
|
|
14
|
+
import {
|
|
15
|
+
deleteBindingAtIndex,
|
|
16
|
+
ensureBindingAcl,
|
|
17
|
+
fixOverPrivilegedBindingAcl,
|
|
18
|
+
} from "../../../components/dialogs/binding/binding-actions.js";
|
|
19
|
+
import type { BindingEntryStruct } from "../../../components/dialogs/binding/model.js";
|
|
20
|
+
import { showNodeBindingDialog } from "../../../components/dialogs/binding/show-node-binding-dialog.js";
|
|
21
|
+
import { nodeIdKey } from "../../../util/access-control.js";
|
|
22
|
+
import { handleAsync } from "../../../util/async-handler.js";
|
|
23
|
+
import { readBindings, reverseAclState, type ReverseAclState } from "../../../util/binding.js";
|
|
24
|
+
import { getEndpointDeviceTypes } from "../../../util/endpoints.js";
|
|
25
|
+
import { getDeviceName } from "../../../util/node-name.js";
|
|
26
|
+
import { BaseClusterCommands } from "../base-cluster-commands.js";
|
|
27
|
+
import { registerClusterCommands } from "../registry.js";
|
|
28
|
+
|
|
29
|
+
const CLUSTER_ID = 30;
|
|
30
|
+
|
|
31
|
+
@customElement("binding-cluster-commands")
|
|
32
|
+
class BindingClusterCommands extends BaseClusterCommands {
|
|
33
|
+
private _unsubscribe?: () => void;
|
|
34
|
+
private _loadedKey = "";
|
|
35
|
+
@state() private _busy = false;
|
|
36
|
+
|
|
37
|
+
override updated(changed: Map<string, unknown>) {
|
|
38
|
+
super.updated(changed);
|
|
39
|
+
if (changed.has("client") && this.client && !this._unsubscribe) {
|
|
40
|
+
this._unsubscribe = this.client.addEventListener("nodes_changed", () => this.requestUpdate());
|
|
41
|
+
}
|
|
42
|
+
void this._ensureLoaded();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override disconnectedCallback() {
|
|
46
|
+
super.disconnectedCallback();
|
|
47
|
+
this._unsubscribe?.();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read the (fabric-scoped) binding attribute and each target's ACL into the cache on open. */
|
|
51
|
+
private async _ensureLoaded() {
|
|
52
|
+
if (!this.client || !this.node || !this.node.available || this.endpoint === undefined) return;
|
|
53
|
+
const key = `${nodeIdKey(this.node.node_id)}/${this.endpoint}`;
|
|
54
|
+
if (this._loadedKey === key) return;
|
|
55
|
+
this._loadedKey = key;
|
|
56
|
+
try {
|
|
57
|
+
await this._readInto(this.node.node_id, [`${this.endpoint}/30/0`, "0/62/5"]);
|
|
58
|
+
const targets = new Set(
|
|
59
|
+
readBindings(this.node, this.endpoint)
|
|
60
|
+
.map(b => (b.node != null ? nodeIdKey(b.node) : undefined))
|
|
61
|
+
.filter((k): k is string => k !== undefined),
|
|
62
|
+
);
|
|
63
|
+
await Promise.all(
|
|
64
|
+
[...targets].map(k => {
|
|
65
|
+
const target = this.client.nodes[k];
|
|
66
|
+
return target?.available ? this._readInto(target.node_id, ["0/31/0", "0/62/5"]) : undefined;
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
this.requestUpdate();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this._loadedKey = ""; // allow retry on the next update after a transient failure
|
|
72
|
+
console.error("Failed to load binding/ACL data", err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async _readInto(nodeId: number | bigint, path: string | string[]) {
|
|
77
|
+
const res = await this.client.readAttribute(nodeId, path);
|
|
78
|
+
const node = this.client.nodes[nodeIdKey(nodeId)];
|
|
79
|
+
if (node) for (const [k, v] of Object.entries(res)) node.attributes[k] = v;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private _clusterName(id: number | undefined): string {
|
|
83
|
+
if (id == null) return "All clusters";
|
|
84
|
+
return `${clusters[id]?.label ?? "Cluster"} (0x${id.toString(16).padStart(2, "0").toUpperCase()})`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private _targetNode(nodeId: number | bigint | undefined) {
|
|
88
|
+
if (nodeId == null) return undefined;
|
|
89
|
+
return this.client.nodes[nodeIdKey(nodeId)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async _openAdd() {
|
|
93
|
+
await showNodeBindingDialog(this.node, this.endpoint);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async _run(action: () => Promise<void>, failTitle: string) {
|
|
97
|
+
this._busy = true;
|
|
98
|
+
try {
|
|
99
|
+
await action();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
await showAlertDialog({ title: failTitle, text: err instanceof Error ? err.message : String(err) });
|
|
102
|
+
} finally {
|
|
103
|
+
this._busy = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async _delete(index: number) {
|
|
108
|
+
const confirmed = await showPromptDialog({
|
|
109
|
+
title: "Remove binding",
|
|
110
|
+
text: "Remove this binding and clean up the matching access control entry on the target node?",
|
|
111
|
+
confirmText: "Remove",
|
|
112
|
+
});
|
|
113
|
+
if (!confirmed) return;
|
|
114
|
+
await this._run(() => deleteBindingAtIndex(this.client, this.node, this.endpoint, index), "Delete failed");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async _fixAcl(b: BindingEntryStruct, mode: "missing" | "overPrivileged") {
|
|
118
|
+
if (b.node == null || b.endpoint == null) return;
|
|
119
|
+
await this._run(
|
|
120
|
+
() =>
|
|
121
|
+
mode === "missing"
|
|
122
|
+
? ensureBindingAcl(this.client, this.node.node_id, b.node!, b.endpoint!, b.cluster)
|
|
123
|
+
: fixOverPrivilegedBindingAcl(this.client, this.node.node_id, b.node!, b.endpoint!, b.cluster),
|
|
124
|
+
"Fix failed",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override render() {
|
|
129
|
+
if (!this.node || this.cluster !== CLUSTER_ID) return nothing;
|
|
130
|
+
const bindings = readBindings(this.node, this.endpoint);
|
|
131
|
+
|
|
132
|
+
return html`
|
|
133
|
+
<details class="command-panel">
|
|
134
|
+
<summary>Bindings (${bindings.length})</summary>
|
|
135
|
+
<div class="command-content">
|
|
136
|
+
${bindings.length === 0
|
|
137
|
+
? html`<div class="empty">No bindings on this endpoint.</div>`
|
|
138
|
+
: html`<table class="bt">
|
|
139
|
+
<thead>
|
|
140
|
+
<tr>
|
|
141
|
+
<th>Target node</th>
|
|
142
|
+
<th>Endpoint</th>
|
|
143
|
+
<th>Cluster</th>
|
|
144
|
+
<th>ACL on target</th>
|
|
145
|
+
<th></th>
|
|
146
|
+
</tr>
|
|
147
|
+
</thead>
|
|
148
|
+
<tbody>
|
|
149
|
+
${bindings.map((b, i) => this._row(b, i))}
|
|
150
|
+
</tbody>
|
|
151
|
+
</table>`}
|
|
152
|
+
<md-outlined-button
|
|
153
|
+
?disabled=${this._busy || !this.node.available}
|
|
154
|
+
@click=${handleAsync(() => this._openAdd())}
|
|
155
|
+
>Add binding</md-outlined-button
|
|
156
|
+
>
|
|
157
|
+
</div>
|
|
158
|
+
</details>
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private _row(b: BindingEntryStruct, index: number): TemplateResult {
|
|
163
|
+
const target = this._targetNode(b.node);
|
|
164
|
+
const aclState: ReverseAclState =
|
|
165
|
+
b.node == null ? "cannotVerify" : reverseAclState(this.node.node_id, b, target).state;
|
|
166
|
+
const name = b.group != null ? `Group ${b.group}` : target ? getDeviceName(target) : "Unknown node";
|
|
167
|
+
const deviceType = b.endpoint != null && target ? getEndpointDeviceTypes(target, b.endpoint)[0] : undefined;
|
|
168
|
+
const endpointText =
|
|
169
|
+
b.endpoint == null ? "—" : deviceType ? `EP ${b.endpoint} · ${deviceType.label}` : `EP ${b.endpoint}`;
|
|
170
|
+
return html`
|
|
171
|
+
<tr>
|
|
172
|
+
<td>
|
|
173
|
+
<span class="ident"
|
|
174
|
+
><b>${name}</b>${b.node != null
|
|
175
|
+
? html` · <span class="nid">${b.node.toString()}</span>`
|
|
176
|
+
: nothing}</span
|
|
177
|
+
>
|
|
178
|
+
</td>
|
|
179
|
+
<td>${endpointText}</td>
|
|
180
|
+
<td>${this._clusterName(b.cluster)}</td>
|
|
181
|
+
<td>${this._aclCell(b, aclState)}</td>
|
|
182
|
+
<td class="actions">
|
|
183
|
+
<md-outlined-button
|
|
184
|
+
class="danger"
|
|
185
|
+
?disabled=${this._busy || !this.node.available}
|
|
186
|
+
@click=${handleAsync(() => this._delete(index))}
|
|
187
|
+
>
|
|
188
|
+
<ha-svg-icon .path=${mdiTrashCan} slot="icon"></ha-svg-icon>delete
|
|
189
|
+
</md-outlined-button>
|
|
190
|
+
</td>
|
|
191
|
+
</tr>
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private _aclCell(b: BindingEntryStruct, state: ReverseAclState): TemplateResult {
|
|
196
|
+
if (state === "present") return html`<span class="status ok">ACL present</span>`;
|
|
197
|
+
if (state === "cannotVerify") return html`<span class="status mut">can't verify</span>`;
|
|
198
|
+
const label = state === "missing" ? "ACL missing" : "ACL > Operate";
|
|
199
|
+
const fixLabel = state === "missing" ? "Fix ACL" : "Fix → Operate";
|
|
200
|
+
return html`
|
|
201
|
+
<span class="status warn">${label}</span>
|
|
202
|
+
<md-outlined-button
|
|
203
|
+
class="fix"
|
|
204
|
+
?disabled=${this._busy || !this.node.available}
|
|
205
|
+
@click=${handleAsync(() => this._fixAcl(b, state))}
|
|
206
|
+
>${fixLabel}</md-outlined-button
|
|
207
|
+
>
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
static override styles: CSSResultGroup = [
|
|
212
|
+
BaseClusterCommands.styles,
|
|
213
|
+
css`
|
|
214
|
+
.bt {
|
|
215
|
+
width: 100%;
|
|
216
|
+
border-collapse: collapse;
|
|
217
|
+
margin-bottom: 12px;
|
|
218
|
+
}
|
|
219
|
+
.bt th {
|
|
220
|
+
text-align: left;
|
|
221
|
+
font-size: 11px;
|
|
222
|
+
text-transform: uppercase;
|
|
223
|
+
opacity: 0.6;
|
|
224
|
+
padding: 8px 10px;
|
|
225
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
226
|
+
}
|
|
227
|
+
.bt td {
|
|
228
|
+
padding: 10px;
|
|
229
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
230
|
+
vertical-align: middle;
|
|
231
|
+
}
|
|
232
|
+
.empty {
|
|
233
|
+
opacity: 0.6;
|
|
234
|
+
padding: 8px 0 12px;
|
|
235
|
+
}
|
|
236
|
+
.ident .nid {
|
|
237
|
+
font-weight: 600;
|
|
238
|
+
}
|
|
239
|
+
.status {
|
|
240
|
+
font-size: inherit;
|
|
241
|
+
}
|
|
242
|
+
.status.ok {
|
|
243
|
+
color: var(--md-sys-color-primary);
|
|
244
|
+
}
|
|
245
|
+
.status.warn {
|
|
246
|
+
color: var(--md-sys-color-error);
|
|
247
|
+
margin-right: 8px;
|
|
248
|
+
}
|
|
249
|
+
.status.mut {
|
|
250
|
+
opacity: 0.6;
|
|
251
|
+
}
|
|
252
|
+
.actions {
|
|
253
|
+
text-align: right;
|
|
254
|
+
white-space: nowrap;
|
|
255
|
+
}
|
|
256
|
+
md-outlined-button.fix {
|
|
257
|
+
--md-outlined-button-container-height: 28px;
|
|
258
|
+
}
|
|
259
|
+
md-outlined-button.danger {
|
|
260
|
+
--md-outlined-button-label-text-color: var(--md-sys-color-error);
|
|
261
|
+
--md-outlined-button-outline-color: var(--md-sys-color-error);
|
|
262
|
+
}
|
|
263
|
+
`,
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
registerClusterCommands(CLUSTER_ID, "binding-cluster-commands");
|
|
268
|
+
|
|
269
|
+
declare global {
|
|
270
|
+
interface HTMLElementTagNameMap {
|
|
271
|
+
"binding-cluster-commands": BindingClusterCommands;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -16,8 +16,10 @@ export { getClusterCommandsTag, hasClusterCommands, registerClusterCommands } fr
|
|
|
16
16
|
export { BaseClusterCommands } from "./base-cluster-commands.js";
|
|
17
17
|
|
|
18
18
|
// Cluster command components (auto-register on import)
|
|
19
|
+
import "./clusters/access-control-commands.js";
|
|
19
20
|
import "./clusters/avsum-commands.js";
|
|
20
21
|
import "./clusters/basic-information-commands.js";
|
|
22
|
+
import "./clusters/binding-commands.js";
|
|
21
23
|
import "./clusters/chime-commands.js";
|
|
22
24
|
import "./clusters/level-control-commands.js";
|
|
23
25
|
import "./clusters/on-off-commands.js";
|