@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.
Files changed (111) hide show
  1. package/dist/esm/components/dialogs/acl/acl-actions.d.ts +12 -0
  2. package/dist/esm/components/dialogs/acl/acl-actions.d.ts.map +1 -0
  3. package/dist/esm/components/dialogs/acl/acl-actions.js +56 -0
  4. package/dist/esm/components/dialogs/acl/acl-actions.js.map +6 -0
  5. package/dist/esm/components/dialogs/acl/model.d.ts +2 -2
  6. package/dist/esm/components/dialogs/acl/model.d.ts.map +1 -1
  7. package/dist/esm/components/dialogs/acl/model.js +11 -15
  8. package/dist/esm/components/dialogs/acl/model.js.map +1 -1
  9. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts +43 -0
  10. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts.map +1 -0
  11. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js +353 -0
  12. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js.map +6 -0
  13. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts +8 -0
  14. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts.map +1 -0
  15. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js +15 -0
  16. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js.map +6 -0
  17. package/dist/esm/components/dialogs/binding/binding-actions.d.ts +17 -0
  18. package/dist/esm/components/dialogs/binding/binding-actions.d.ts.map +1 -0
  19. package/dist/esm/components/dialogs/binding/binding-actions.js +135 -0
  20. package/dist/esm/components/dialogs/binding/binding-actions.js.map +6 -0
  21. package/dist/esm/components/dialogs/binding/model.d.ts +1 -1
  22. package/dist/esm/components/dialogs/binding/model.d.ts.map +1 -1
  23. package/dist/esm/components/dialogs/binding/model.js +8 -3
  24. package/dist/esm/components/dialogs/binding/model.js.map +1 -1
  25. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts +16 -24
  26. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
  27. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +210 -332
  28. package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
  29. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts +37 -0
  30. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts.map +1 -0
  31. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js +387 -0
  32. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js.map +6 -0
  33. package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts +35 -0
  34. package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts.map +1 -0
  35. package/dist/esm/pages/cluster-commands/clusters/binding-commands.js +254 -0
  36. package/dist/esm/pages/cluster-commands/clusters/binding-commands.js.map +6 -0
  37. package/dist/esm/pages/cluster-commands/index.d.ts +2 -0
  38. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  39. package/dist/esm/pages/cluster-commands/index.js +2 -0
  40. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  41. package/dist/esm/pages/components/node-details.d.ts +0 -1
  42. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  43. package/dist/esm/pages/components/node-details.js +2 -18
  44. package/dist/esm/pages/components/node-details.js.map +1 -1
  45. package/dist/esm/pages/components/server-details.js +3 -3
  46. package/dist/esm/pages/components/server-details.js.map +1 -1
  47. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  48. package/dist/esm/pages/matter-cluster-view.js +2 -1
  49. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  50. package/dist/esm/pages/matter-endpoint-view.d.ts +3 -3
  51. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  52. package/dist/esm/pages/matter-endpoint-view.js +13 -10
  53. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  54. package/dist/esm/pages/matter-node-view.js +2 -2
  55. package/dist/esm/pages/matter-node-view.js.map +1 -1
  56. package/dist/esm/pages/network/network-utils.d.ts +1 -5
  57. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  58. package/dist/esm/pages/network/network-utils.js +1 -11
  59. package/dist/esm/pages/network/network-utils.js.map +1 -1
  60. package/dist/esm/util/access-control.d.ts +54 -0
  61. package/dist/esm/util/access-control.d.ts.map +1 -0
  62. package/dist/esm/util/access-control.js +100 -0
  63. package/dist/esm/util/access-control.js.map +6 -0
  64. package/dist/esm/util/binding.d.ts +54 -0
  65. package/dist/esm/util/binding.d.ts.map +1 -0
  66. package/dist/esm/util/binding.js +113 -0
  67. package/dist/esm/util/binding.js.map +6 -0
  68. package/dist/esm/util/endpoints.d.ts +9 -0
  69. package/dist/esm/util/endpoints.d.ts.map +1 -0
  70. package/dist/esm/util/endpoints.js +18 -0
  71. package/dist/esm/util/endpoints.js.map +6 -0
  72. package/dist/esm/util/node-name.d.ts +12 -0
  73. package/dist/esm/util/node-name.d.ts.map +1 -0
  74. package/dist/esm/util/node-name.js +15 -0
  75. package/dist/esm/util/node-name.js.map +6 -0
  76. package/dist/web/js/{attribute-write-dialog-W7xpCE2E.js → attribute-write-dialog-CqqdRniU.js} +1 -1
  77. package/dist/web/js/{command-invoke-dialog-BAqAAdJw.js → command-invoke-dialog-BuvBOrdC.js} +1 -1
  78. package/dist/web/js/{commission-node-dialog-BTzCGgdy.js → commission-node-dialog-nVZp3go0.js} +5 -5
  79. package/dist/web/js/{commission-node-existing-B2M2hyDh.js → commission-node-existing-Cx3Ahk2t.js} +2 -2
  80. package/dist/web/js/{commission-node-thread-djdz2dXW.js → commission-node-thread-CI8mWWPs.js} +2 -2
  81. package/dist/web/js/{commission-node-wifi-DxAYNS1A.js → commission-node-wifi-lUX4LK2R.js} +2 -2
  82. package/dist/web/js/{dialog-box-tHvPVxDN.js → dialog-box-bAdbnf-T.js} +1 -1
  83. package/dist/web/js/{fire_event-BleYfTLc.js → fire_event-tWhqPfdz.js} +1 -1
  84. package/dist/web/js/main.js +1 -1
  85. package/dist/web/js/{matter-dashboard-app-CVi_GDky.js → matter-dashboard-app-CONA_608.js} +1485 -390
  86. package/dist/web/js/node-acl-add-dialog-DlR-sF-b.js +320 -0
  87. package/dist/web/js/node-binding-dialog-DhnX_86M.js +267 -0
  88. package/dist/web/js/{node-label-dialog-DVZSjsXU.js → node-label-dialog-T3nPG-Qy.js} +1 -1
  89. package/dist/web/js/{settings-dialog-BG5MgZcO.js → settings-dialog-DrHzJtsi.js} +1 -1
  90. package/package.json +4 -4
  91. package/src/components/dialogs/acl/acl-actions.ts +71 -0
  92. package/src/components/dialogs/acl/model.ts +18 -17
  93. package/src/components/dialogs/acl/node-acl-add-dialog.ts +350 -0
  94. package/src/components/dialogs/acl/show-node-acl-add-dialog.ts +14 -0
  95. package/src/components/dialogs/binding/binding-actions.ts +201 -0
  96. package/src/components/dialogs/binding/model.ts +11 -4
  97. package/src/components/dialogs/binding/node-binding-dialog.ts +221 -399
  98. package/src/pages/cluster-commands/clusters/access-control-commands.ts +407 -0
  99. package/src/pages/cluster-commands/clusters/binding-commands.ts +273 -0
  100. package/src/pages/cluster-commands/index.ts +2 -0
  101. package/src/pages/components/node-details.ts +2 -21
  102. package/src/pages/components/server-details.ts +3 -3
  103. package/src/pages/matter-cluster-view.ts +4 -1
  104. package/src/pages/matter-endpoint-view.ts +16 -10
  105. package/src/pages/matter-node-view.ts +2 -2
  106. package/src/pages/network/network-utils.ts +1 -18
  107. package/src/util/access-control.ts +135 -0
  108. package/src/util/binding.ts +182 -0
  109. package/src/util/endpoints.ts +17 -0
  110. package/src/util/node-name.ts +18 -0
  111. package/dist/web/js/node-binding-dialog-B9IdqHrZ.js +0 -624
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { AccessControlEntry, MatterClient } from "@matter-server/ws-client";
8
+ import { Privilege, aclEntryKey, attributeArray, entriesForFabric } from "../../../util/access-control.js";
9
+ import { AccessControlEntryDataTransformer, type AccessControlEntryStruct } from "./model.js";
10
+
11
+ function toApiAcl(e: AccessControlEntryStruct): AccessControlEntry {
12
+ return {
13
+ privilege: e.privilege,
14
+ auth_mode: e.authMode,
15
+ subjects: e.subjects ?? null,
16
+ targets:
17
+ e.targets?.map(t => ({
18
+ cluster: t.cluster ?? null,
19
+ endpoint: t.endpoint ?? null,
20
+ device_type: t.deviceType ?? null,
21
+ })) ?? null,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Read the node's ACL + CurrentFabricIndex fresh (explicit reads are not fabric-filtered) and narrow
27
+ * to our fabric. Fails rather than risk writing back other fabrics' entries if the index is unknown.
28
+ */
29
+ async function freshOurAcl(client: MatterClient, nodeId: number | bigint): Promise<AccessControlEntryStruct[]> {
30
+ const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"]);
31
+ const all = attributeArray(res["0/31/0"]).map(v => AccessControlEntryDataTransformer.transform(v));
32
+ const fi = res["0/62/5"];
33
+ if (typeof fi !== "number") {
34
+ throw new Error(`Cannot determine the current fabric index (0/62/5) for node ${nodeId}`);
35
+ }
36
+ return entriesForFabric(all, fi);
37
+ }
38
+
39
+ export async function addAclEntry(
40
+ client: MatterClient,
41
+ nodeId: number | bigint,
42
+ entry: AccessControlEntryStruct,
43
+ ): Promise<void> {
44
+ const acl = await freshOurAcl(client, nodeId);
45
+ acl.push(entry);
46
+ await client.setACLEntry(nodeId, acl.map(toApiAcl));
47
+ }
48
+
49
+ export async function deleteAclEntry(client: MatterClient, nodeId: number | bigint, key: string): Promise<void> {
50
+ const acl = await freshOurAcl(client, nodeId);
51
+ let removed = false;
52
+ const kept = acl.filter(e => {
53
+ if (!removed && aclEntryKey(e) === key) {
54
+ removed = true;
55
+ return false;
56
+ }
57
+ return true;
58
+ });
59
+ await client.setACLEntry(nodeId, kept.map(toApiAcl));
60
+ }
61
+
62
+ /** Downgrade the given entries (by key) to Operate privilege. */
63
+ export async function downgradeToOperate(
64
+ client: MatterClient,
65
+ nodeId: number | bigint,
66
+ keys: Set<string>,
67
+ ): Promise<void> {
68
+ const acl = await freshOurAcl(client, nodeId);
69
+ const updated = acl.map(e => (keys.has(aclEntryKey(e)) ? { ...e, privilege: Privilege.Operate } : e));
70
+ await client.setACLEntry(nodeId, updated.map(toApiAcl));
71
+ }
@@ -26,6 +26,10 @@ export type AccessControlEntryStruct = {
26
26
  fabricIndex: number;
27
27
  };
28
28
 
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null;
31
+ }
32
+
29
33
  export class AccessControlTargetTransformer {
30
34
  private static readonly KEY_MAPPING: {
31
35
  [inputKey: string]: keyof AccessControlTargetStruct;
@@ -35,8 +39,8 @@ export class AccessControlTargetTransformer {
35
39
  "2": "deviceType",
36
40
  };
37
41
 
38
- public static transform(input: any): AccessControlTargetStruct {
39
- if (!input || typeof input !== "object") {
42
+ public static transform(input: unknown): AccessControlTargetStruct {
43
+ if (!isRecord(input)) {
40
44
  throw new Error("Invalid input: expected an object");
41
45
  }
42
46
 
@@ -48,8 +52,9 @@ export class AccessControlTargetTransformer {
48
52
  const mappedKey = keyMapping[key];
49
53
  if (mappedKey) {
50
54
  const value = input[key];
51
- if (value === undefined) continue;
52
- result[mappedKey] = value;
55
+ // Treat unset/wildcard fields (null or absent) as omitted, not numeric 0.
56
+ if (value == null) continue;
57
+ result[mappedKey] = Number(value);
53
58
  }
54
59
  }
55
60
  }
@@ -68,8 +73,8 @@ export class AccessControlEntryDataTransformer {
68
73
  "254": "fabricIndex",
69
74
  };
70
75
 
71
- public static transform(input: any): AccessControlEntryStruct {
72
- if (!input || typeof input !== "object") {
76
+ public static transform(input: unknown): AccessControlEntryStruct {
77
+ if (!isRecord(input)) {
73
78
  throw new Error("Invalid input: expected an object");
74
79
  }
75
80
 
@@ -81,20 +86,16 @@ export class AccessControlEntryDataTransformer {
81
86
  const mappedKey = keyMapping[key];
82
87
  if (mappedKey) {
83
88
  const value = input[key];
84
- if (value === undefined) continue;
89
+ if (value == null) continue;
85
90
  if (mappedKey === "subjects") {
86
- result[mappedKey] = Array.isArray(value) ? value : undefined;
91
+ result.subjects = Array.isArray(value) ? value : undefined;
87
92
  } else if (mappedKey === "targets") {
88
- if (Array.isArray(value)) {
89
- const _targets = Object.values(value).map(val =>
90
- AccessControlTargetTransformer.transform(val),
91
- );
92
- result[mappedKey] = _targets;
93
- } else {
94
- result[mappedKey] = undefined;
95
- }
93
+ result.targets = Array.isArray(value)
94
+ ? value.map(val => AccessControlTargetTransformer.transform(val))
95
+ : undefined;
96
96
  } else {
97
- result[mappedKey] = value;
97
+ // privilege, authMode, fabricIndex are numeric
98
+ result[mappedKey] = Number(value);
98
99
  }
99
100
  }
100
101
  }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/button/text-button";
8
+ import "@material/web/dialog/dialog";
9
+ import "@material/web/select/outlined-select";
10
+ import "@material/web/select/select-option";
11
+ import "@material/web/textfield/outlined-text-field";
12
+ import { consume } from "@lit/context";
13
+ import type { MdDialog } from "@material/web/dialog/dialog.js";
14
+ import { MatterClient, MatterNode } from "@matter-server/ws-client";
15
+ import { mdiClose } from "@mdi/js";
16
+ import { css, html, LitElement } from "lit";
17
+ import { customElement, property, state } from "lit/decorators.js";
18
+ import "../../../components/ha-svg-icon.js";
19
+ import { clientContext } from "../../../client/client-context.js";
20
+ import { clusters } from "../../../client/models/descriptions.js";
21
+ import { AuthMode, Privilege, PRIVILEGE_NAMES, aclCapacity, nodeIdKey } from "../../../util/access-control.js";
22
+ import { handleAsync } from "../../../util/async-handler.js";
23
+ import { targetServerClusters } from "../../../util/binding.js";
24
+ import { getDeviceName } from "../../../util/node-name.js";
25
+ import { preventDefault } from "../../../util/prevent_default.js";
26
+ import { showAlertDialog } from "../../dialog-box/show-dialog-box.js";
27
+ import { addAclEntry } from "./acl-actions.js";
28
+ import type { AccessControlEntryStruct, AccessControlTargetStruct } from "./model.js";
29
+
30
+ @customElement("node-acl-add-dialog")
31
+ export class NodeAclAddDialog extends LitElement {
32
+ @consume({ context: clientContext, subscribe: true })
33
+ @property({ attribute: false })
34
+ public client!: MatterClient;
35
+
36
+ @property({ attribute: false })
37
+ public node!: MatterNode;
38
+
39
+ @state() private _privilege = Privilege.Operate;
40
+ @state() private _subjects = new Array<number | bigint>();
41
+ @state() private _subjectInput = "";
42
+ @state() private _targets = new Array<AccessControlTargetStruct>();
43
+ @state() private _targetEndpoint = "all";
44
+ @state() private _targetCluster = "";
45
+ @state() private _busy = false;
46
+
47
+ private _knownNodes(): MatterNode[] {
48
+ return Object.values(this.client.nodes).sort((a, b) => {
49
+ const x = BigInt(a.node_id);
50
+ const y = BigInt(b.node_id);
51
+ return x < y ? -1 : x > y ? 1 : 0;
52
+ });
53
+ }
54
+
55
+ private _addSubject(raw: string) {
56
+ const value = raw.trim();
57
+ if (!/^\d+$/.test(value)) return;
58
+ const id = BigInt(value);
59
+ const key = nodeIdKey(id);
60
+ if (this._subjects.some(s => nodeIdKey(s) === key)) return;
61
+ const max = aclCapacity(this.node).subjectsMax;
62
+ if (max > 0 && this._subjects.length >= max) {
63
+ void showAlertDialog({ title: "Limit reached", text: `At most ${max} subjects per entry.` });
64
+ return;
65
+ }
66
+ this._subjects = [...this._subjects, id];
67
+ this._subjectInput = "";
68
+ }
69
+
70
+ private _removeSubject(key: string) {
71
+ this._subjects = this._subjects.filter(s => nodeIdKey(s) !== key);
72
+ }
73
+
74
+ private _nodeEndpoints(): number[] {
75
+ const eps = new Set<number>();
76
+ for (const key of Object.keys(this.node.attributes)) {
77
+ const m = /^(\d+)\/29\/0$/.exec(key);
78
+ if (m) eps.add(Number(m[1]));
79
+ }
80
+ return Array.from(eps).sort((a, b) => a - b);
81
+ }
82
+
83
+ private _clusterOptions(): number[] {
84
+ if (this._targetEndpoint === "all") {
85
+ const all = new Set<number>();
86
+ for (const ep of this._nodeEndpoints()) targetServerClusters(this.node, ep).forEach(c => all.add(c));
87
+ return Array.from(all).sort((a, b) => a - b);
88
+ }
89
+ return targetServerClusters(this.node, Number(this._targetEndpoint)).sort((a, b) => a - b);
90
+ }
91
+
92
+ private _clusterLabel(id: number): string {
93
+ return `${clusters[id]?.label ?? "Cluster"} (0x${id.toString(16).padStart(2, "0").toUpperCase()})`;
94
+ }
95
+
96
+ private _addTarget() {
97
+ const max = aclCapacity(this.node).targetsMax;
98
+ if (max > 0 && this._targets.length >= max) {
99
+ void showAlertDialog({ title: "Limit reached", text: `At most ${max} targets per entry.` });
100
+ return;
101
+ }
102
+ const endpoint =
103
+ this._targetEndpoint === "all" || this._targetEndpoint === "" ? undefined : Number(this._targetEndpoint);
104
+ const cluster =
105
+ this._targetCluster === "all" || this._targetCluster === "" ? undefined : Number(this._targetCluster);
106
+ if (endpoint === undefined && cluster === undefined) {
107
+ void showAlertDialog({
108
+ title: "Validation error",
109
+ text: "Pick an endpoint and/or a cluster for the target.",
110
+ });
111
+ return;
112
+ }
113
+ this._targets = [...this._targets, { endpoint, cluster, deviceType: undefined }];
114
+ this._targetEndpoint = "all";
115
+ this._targetCluster = "all";
116
+ }
117
+
118
+ private _removeTarget(index: number) {
119
+ this._targets = this._targets.filter((_, i) => i !== index);
120
+ }
121
+
122
+ private async _save() {
123
+ if (this._subjects.length === 0) {
124
+ await showAlertDialog({ title: "Validation error", text: "Add at least one subject node." });
125
+ return;
126
+ }
127
+ const entry: AccessControlEntryStruct = {
128
+ privilege: this._privilege,
129
+ authMode: AuthMode.Case,
130
+ subjects: this._subjects,
131
+ targets: this._targets.length ? this._targets : undefined,
132
+ fabricIndex: 0,
133
+ };
134
+ this._busy = true;
135
+ try {
136
+ await addAclEntry(this.client, this.node.node_id, entry);
137
+ this._close();
138
+ } catch (err) {
139
+ await showAlertDialog({
140
+ title: "Failed to add entry",
141
+ text: err instanceof Error ? err.message : String(err),
142
+ });
143
+ } finally {
144
+ this._busy = false;
145
+ }
146
+ }
147
+
148
+ private _close() {
149
+ this.shadowRoot!.querySelector<MdDialog>("md-dialog")!.close();
150
+ }
151
+
152
+ private _handleClosed() {
153
+ this.parentNode?.removeChild(this);
154
+ }
155
+
156
+ protected override render() {
157
+ return html`
158
+ <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
159
+ <div slot="headline">Add ACL entry</div>
160
+ <div slot="content">
161
+ <div class="form">
162
+ <md-outlined-select
163
+ label="Privilege"
164
+ .value=${String(this._privilege)}
165
+ ?disabled=${this._busy}
166
+ @change=${(e: Event) => (this._privilege = Number((e.target as HTMLSelectElement).value))}
167
+ >
168
+ ${[Privilege.View, Privilege.Operate, Privilege.Manage, Privilege.Administer].map(
169
+ p =>
170
+ html`<md-select-option value=${String(p)}
171
+ ><div slot="headline">${PRIVILEGE_NAMES[p]} · ${p}</div></md-select-option
172
+ >`,
173
+ )}
174
+ </md-outlined-select>
175
+ <div class="note">Auth mode: CASE (node). Group subjects are not supported yet.</div>
176
+
177
+ <div class="label">Subjects (nodes)</div>
178
+ <div class="chips">
179
+ ${this._subjects.length === 0
180
+ ? html`<span class="mut">none — add at least one</span>`
181
+ : this._subjects.map(s => {
182
+ const known = this.client.nodes[nodeIdKey(s)];
183
+ return html`<span class="chip"
184
+ >${known ? getDeviceName(known) : "Node"} · ${s.toString()}
185
+ <ha-svg-icon
186
+ class="x"
187
+ .path=${mdiClose}
188
+ @click=${() => this._removeSubject(nodeIdKey(s))}
189
+ ></ha-svg-icon
190
+ ></span>`;
191
+ })}
192
+ </div>
193
+ <div class="row">
194
+ <md-outlined-select
195
+ label="Known nodes"
196
+ ?disabled=${this._busy}
197
+ @change=${(e: Event) => {
198
+ const v = (e.target as HTMLSelectElement).value;
199
+ if (v) this._addSubject(v);
200
+ }}
201
+ >
202
+ <md-select-option value=""><div slot="headline">— pick —</div></md-select-option>
203
+ ${this._knownNodes().map(
204
+ n =>
205
+ html`<md-select-option value=${nodeIdKey(n.node_id)}
206
+ ><div slot="headline">
207
+ ${n.node_id.toString()} · ${getDeviceName(n)}
208
+ </div></md-select-option
209
+ >`,
210
+ )}
211
+ </md-outlined-select>
212
+ <md-outlined-text-field
213
+ label="or raw node id"
214
+ type="text"
215
+ pattern="[0-9]+"
216
+ .value=${this._subjectInput}
217
+ ?disabled=${this._busy}
218
+ @input=${(e: Event) => (this._subjectInput = (e.target as HTMLInputElement).value)}
219
+ ></md-outlined-text-field>
220
+ <md-text-button ?disabled=${this._busy} @click=${() => this._addSubject(this._subjectInput)}
221
+ >Add</md-text-button
222
+ >
223
+ </div>
224
+
225
+ <div class="label">Targets (optional — none means whole node)</div>
226
+ <div class="chips">
227
+ ${this._targets.length === 0
228
+ ? html`<span class="mut">whole node</span>`
229
+ : this._targets.map(
230
+ (t, i) =>
231
+ html`<span class="chip"
232
+ >${t.endpoint != null ? `EP ${t.endpoint}` : "All endpoints"}
233
+ ${t.cluster != null
234
+ ? `· ${this._clusterLabel(t.cluster)}`
235
+ : "· all clusters"}
236
+ <ha-svg-icon
237
+ class="x"
238
+ .path=${mdiClose}
239
+ @click=${() => this._removeTarget(i)}
240
+ ></ha-svg-icon
241
+ ></span>`,
242
+ )}
243
+ </div>
244
+ <div class="row">
245
+ <md-outlined-select
246
+ label="endpoint"
247
+ .value=${this._targetEndpoint}
248
+ ?disabled=${this._busy}
249
+ @change=${(e: Event) => {
250
+ this._targetEndpoint = (e.target as HTMLSelectElement).value;
251
+ this._targetCluster = "";
252
+ }}
253
+ >
254
+ <md-select-option value="all"
255
+ ><div slot="headline">All endpoints</div></md-select-option
256
+ >
257
+ ${this._nodeEndpoints().map(
258
+ ep =>
259
+ html`<md-select-option value=${String(ep)}
260
+ ><div slot="headline">EP ${ep}</div></md-select-option
261
+ >`,
262
+ )}
263
+ </md-outlined-select>
264
+ <md-outlined-select
265
+ label="cluster"
266
+ .value=${this._targetCluster}
267
+ ?disabled=${this._busy}
268
+ @change=${(e: Event) => (this._targetCluster = (e.target as HTMLSelectElement).value)}
269
+ >
270
+ <md-select-option value="all"><div slot="headline">All clusters</div></md-select-option>
271
+ ${this._clusterOptions().map(
272
+ c =>
273
+ html`<md-select-option value=${String(c)}
274
+ ><div slot="headline">${this._clusterLabel(c)}</div></md-select-option
275
+ >`,
276
+ )}
277
+ </md-outlined-select>
278
+ <md-text-button ?disabled=${this._busy} @click=${() => this._addTarget()}
279
+ >Add target</md-text-button
280
+ >
281
+ </div>
282
+ </div>
283
+ </div>
284
+ <div slot="actions">
285
+ <md-text-button ?disabled=${this._busy} @click=${handleAsync(() => this._save())}
286
+ >Add</md-text-button
287
+ >
288
+ <md-text-button ?disabled=${this._busy} @click=${this._close}>Cancel</md-text-button>
289
+ </div>
290
+ </md-dialog>
291
+ `;
292
+ }
293
+
294
+ static override styles = css`
295
+ .form {
296
+ display: flex;
297
+ flex-direction: column;
298
+ gap: 10px;
299
+ min-width: 360px;
300
+ }
301
+ .label {
302
+ font-size: 11px;
303
+ text-transform: uppercase;
304
+ letter-spacing: 0.04em;
305
+ opacity: 0.65;
306
+ margin-top: 6px;
307
+ }
308
+ .note {
309
+ font-size: 12px;
310
+ opacity: 0.7;
311
+ }
312
+ .row {
313
+ display: flex;
314
+ gap: 8px;
315
+ align-items: center;
316
+ flex-wrap: wrap;
317
+ }
318
+ .chips {
319
+ display: flex;
320
+ flex-wrap: wrap;
321
+ gap: 6px;
322
+ }
323
+ .chip {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ gap: 4px;
327
+ padding: 3px 8px;
328
+ border-radius: 6px;
329
+ font-size: 12px;
330
+ background: var(--md-sys-color-surface-container-high);
331
+ color: var(--md-sys-color-on-surface);
332
+ }
333
+ .chip .x {
334
+ cursor: pointer;
335
+ --mdc-icon-size: 16px;
336
+ width: 16px;
337
+ height: 16px;
338
+ }
339
+ .mut {
340
+ opacity: 0.6;
341
+ font-size: 12px;
342
+ }
343
+ `;
344
+ }
345
+
346
+ declare global {
347
+ interface HTMLElementTagNameMap {
348
+ "node-acl-add-dialog": NodeAclAddDialog;
349
+ }
350
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { MatterNode } from "@matter-server/ws-client";
8
+
9
+ export const showNodeAclAddDialog = async (node: MatterNode) => {
10
+ await import("./node-acl-add-dialog.js");
11
+ const dialog = document.createElement("node-acl-add-dialog");
12
+ dialog.node = node;
13
+ document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
14
+ };