@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,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:
|
|
39
|
-
if (!input
|
|
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
|
-
|
|
52
|
-
|
|
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:
|
|
72
|
-
if (!input
|
|
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
|
|
89
|
+
if (value == null) continue;
|
|
85
90
|
if (mappedKey === "subjects") {
|
|
86
|
-
result
|
|
91
|
+
result.subjects = Array.isArray(value) ? value : undefined;
|
|
87
92
|
} else if (mappedKey === "targets") {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
+
};
|