This commit is contained in:
Jonas Bengtsson
2026-09-17 06:23:29 +02:00
parent 8d2c54beae
commit ee4800993d
17 changed files with 1043 additions and 189 deletions
+54 -3
View File
@@ -12,6 +12,35 @@ export class ApiError extends Error {
}
}
// FastAPI errors arrive as {detail: string} or {detail: ValidationIssue[]}
// (422) or {error: string}. Arrays MUST be flattened to a string before
// rendering — React 19 throws "Objects are not valid as a React child"
// on a raw array/object child.
function errorMessage(body: unknown): string | null {
if (typeof body !== "object" || body === null) return null;
const record = body as Record<string, unknown>;
const detail = record.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail
.map((issue) => {
if (typeof issue === "string") return issue;
const issueRecord = issue as Record<string, unknown>;
const loc = Array.isArray(issueRecord.loc)
? (issueRecord.loc as unknown[]).slice(-1).join("")
: "";
const msg =
typeof issueRecord.msg === "string"
? issueRecord.msg
: JSON.stringify(issue);
return loc ? `${loc}: ${msg}` : msg;
})
.join("; ");
}
if (typeof record.error === "string") return record.error;
return null;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
credentials: "include",
@@ -22,7 +51,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
let message = response.statusText;
try {
const body = await response.json();
message = body.detail ?? body.error ?? message;
message = errorMessage(body) ?? message;
} catch {
/* non-JSON error body */
}
@@ -43,6 +72,26 @@ export const api = {
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
// --- Assignment operations (spec sections 11-12) ---------------------------
export const assignClientRole = (username: string, rolename: string, priority = -1) =>
api.post(`/mqtt/clients/${username}/roles`, { rolename, priority });
export const removeClientRole = (username: string, rolename: string) =>
api.delete(`/mqtt/clients/${username}/roles/${rolename}`);
export const assignGroupMember = (groupname: string, username: string, priority = -1) =>
api.post(`/mqtt/groups/${groupname}/clients`, { username, priority });
export const removeGroupMember = (groupname: string, username: string) =>
api.delete(`/mqtt/groups/${groupname}/clients/${username}`);
export const assignGroupRole = (groupname: string, rolename: string, priority = -1) =>
api.post(`/mqtt/groups/${groupname}/roles`, { rolename, priority });
export const removeGroupRole = (groupname: string, rolename: string) =>
api.delete(`/mqtt/groups/${groupname}/roles/${rolename}`);
// --- Types (mirrors backend schemas) ---------------------------------------
export interface SessionInfo {
@@ -58,10 +107,12 @@ export interface AclRule {
priority: number;
}
// Wire format note: dynsec uses textname/textdescription (no underscore);
// the backend getRole/listRoles responses pass these through as-is.
export interface Role {
rolename: string;
text_name?: string | null;
text_description?: string | null;
textname?: string | null;
textdescription?: string | null;
acls: AclRule[];
}
+194 -32
View File
@@ -1,6 +1,55 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type MqttClient, type PasswordReset } from "../api";
import {
api,
assignClientRole,
assignGroupMember,
removeClientRole,
removeGroupMember,
type MqttClient,
type PasswordReset,
type Role,
type Group,
} from "../api";
/** Small inline "chip + remove" editor for one client row. */
function AssignmentChips({
items,
onRemove,
}: {
items: { name: string }[];
onRemove: (name: string) => void;
}) {
if (items.length === 0) return <>—</>;
return (
<>
{items.map((item) => (
<span
key={item.name}
className="mono"
style={{
display: "inline-block",
border: "1px solid #cbd2d9",
borderRadius: "10px",
padding: "0.05rem 0.4rem",
margin: "0.1rem",
fontSize: "0.8rem",
}}
>
{item.name}{" "}
<button
className="danger"
style={{ padding: "0 0.3rem", marginLeft: "0.15rem" }}
title={`Remove ${item.name}`}
onClick={() => onRemove(item.name)}
>
×
</button>
</span>
))}
</>
);
}
export function ClientsPage() {
const queryClient = useQueryClient();
@@ -13,30 +62,72 @@ export function ClientsPage() {
queryFn: () => api.get<MqttClient[]>("/mqtt/clients"),
});
// Roles and groups are needed for the assignment dropdowns.
const roles = useQuery({
queryKey: ["roles"],
queryFn: () => api.get<Role[]>("/mqtt/roles"),
});
const groups = useQuery({
queryKey: ["groups"],
queryFn: () => api.get<Group[]>("/mqtt/groups"),
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["clients"] });
};
const create = useMutation({
mutationFn: (username: string) =>
api.post<PasswordReset>("/mqtt/clients", { username }),
onSuccess: (result) => {
setOncePassword(result);
setNewUsername("");
queryClient.invalidateQueries({ queryKey: ["clients"] });
refresh();
},
onError: (e: Error) => setError(e.message),
});
const disable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
onSuccess: refresh,
});
const enable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const assignRole = useMutation({
mutationFn: ({ username, rolename }: { username: string; rolename: string }) =>
assignClientRole(username, rolename),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const unassignRole = useMutation({
mutationFn: ({ username, rolename }: { username: string; rolename: string }) =>
removeClientRole(username, rolename),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const joinGroup = useMutation({
mutationFn: ({ groupname, username }: { groupname: string; username: string }) =>
assignGroupMember(groupname, username),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const leaveGroup = useMutation({
mutationFn: ({ groupname, username }: { groupname: string; username: string }) =>
removeGroupMember(groupname, username),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
@@ -46,12 +137,16 @@ export function ClientsPage() {
if (newUsername) create.mutate(newUsername);
};
const roleNames = (roles.data ?? []).map((r) => r.rolename);
const groupNames = (groups.data ?? []).map((g) => g.groupname);
return (
<div>
<h2>Clients</h2>
<p>
MQTT identities managed by the broker's dynamic security plugin. Passwords
are shown exactly once on creation or reset.
are shown exactly once on creation or reset. Use the selectors to assign
roles or group memberships directly.
</p>
{oncePassword && (
@@ -86,37 +181,104 @@ export function ClientsPage() {
<th>Username</th>
<th>Client ID</th>
<th>Enabled</th>
<th>Groups</th>
<th>Roles</th>
<th>Groups</th>
<th>Assign</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(clients.data ?? []).map((c) => (
<tr key={c.username}>
<td className="mono">{c.username}</td>
<td className="mono">{c.clientid ?? "—"}</td>
<td>{c.enabled ? "yes" : "no"}</td>
<td>{c.groups.map((g) => g.groupname).join(", ") || "—"}</td>
<td>{c.roles.map((r) => r.rolename).join(", ") || "—"}</td>
<td>
{c.enabled ? (
<button onClick={() => disable.mutate(c.username)}>Disable</button>
) : (
<button onClick={() => enable.mutate(c.username)}>Enable</button>
)}{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete client ${c.username}?`))
remove.mutate(c.username);
}}
>
Delete
</button>
</td>
</tr>
))}
{(clients.data ?? []).map((c) => {
const assignedRoles = c.roles.map((r) => ({ name: r.rolename }));
const assignedGroups = c.groups.map((g) => ({ name: g.groupname }));
const freeRoles = roleNames.filter(
(name) => !assignedRoles.some((a) => a.name === name),
);
const freeGroups = groupNames.filter(
(name) => !assignedGroups.some((a) => a.name === name),
);
return (
<tr key={c.username}>
<td className="mono">{c.username}</td>
<td className="mono">{c.clientid ?? "—"}</td>
<td>{c.enabled ? "yes" : "no"}</td>
<td>
<AssignmentChips
items={assignedRoles}
onRemove={(rolename) =>
unassignRole.mutate({ username: c.username, rolename })
}
/>
</td>
<td>
<AssignmentChips
items={assignedGroups}
onRemove={(groupname) =>
leaveGroup.mutate({ groupname, username: c.username })
}
/>
</td>
<td>
{freeRoles.length > 0 ? (
<select
defaultValue=""
onChange={(e) => {
if (e.target.value)
assignRole.mutate({
username: c.username,
rolename: e.target.value,
});
e.target.value = "";
}}
>
<option value="">+ role…</option>
{freeRoles.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
) : null}{" "}
{freeGroups.length > 0 ? (
<select
defaultValue=""
onChange={(e) => {
if (e.target.value)
joinGroup.mutate({
groupname: e.target.value,
username: c.username,
});
e.target.value = "";
}}
>
<option value="">+ group…</option>
{freeGroups.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
) : null}
</td>
<td>
{c.enabled ? (
<button onClick={() => disable.mutate(c.username)}>Disable</button>
) : (
<button onClick={() => enable.mutate(c.username)}>Enable</button>
)}{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete client ${c.username}?`))
remove.mutate(c.username);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
+199 -20
View File
@@ -1,6 +1,15 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Group } from "../api";
import {
api,
assignGroupMember,
assignGroupRole,
removeGroupMember,
removeGroupRole,
type Group,
type MqttClient,
type Role,
} from "../api";
export function GroupsPage() {
const queryClient = useQueryClient();
@@ -12,18 +21,61 @@ export function GroupsPage() {
queryFn: () => api.get<Group[]>("/mqtt/groups"),
});
// For the per-group assignment dropdowns.
const roles = useQuery({
queryKey: ["roles"],
queryFn: () => api.get<Role[]>("/mqtt/roles"),
});
const clients = useQuery({
queryKey: ["clients"],
queryFn: () => api.get<MqttClient[]>("/mqtt/clients"),
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["groups"] });
queryClient.invalidateQueries({ queryKey: ["clients"] });
};
const create = useMutation({
mutationFn: (groupname: string) => api.post("/mqtt/groups", { groupname }),
onSuccess: () => {
setName("");
queryClient.invalidateQueries({ queryKey: ["groups"] });
refresh();
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["groups"] }),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const addRole = useMutation({
mutationFn: ({ groupname, rolename }: { groupname: string; rolename: string }) =>
assignGroupRole(groupname, rolename),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const dropRole = useMutation({
mutationFn: ({ groupname, rolename }: { groupname: string; rolename: string }) =>
removeGroupRole(groupname, rolename),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const addMember = useMutation({
mutationFn: ({ groupname, username }: { groupname: string; username: string }) =>
assignGroupMember(groupname, username),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const dropMember = useMutation({
mutationFn: ({ groupname, username }: { groupname: string; username: string }) =>
removeGroupMember(groupname, username),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
@@ -32,10 +84,23 @@ export function GroupsPage() {
if (name) create.mutate(name);
};
const roleNames = (roles.data ?? []).map((r) => r.rolename);
const clientNames = (clients.data ?? []).map((c) => c.username);
// Group membership lives on the client objects (c.groups), so derive
// members from the clients query rather than the group list.
const membersOf = (groupname: string) =>
(clients.data ?? [])
.filter((c) => c.groups.some((g) => g.groupname === groupname))
.map((c) => ({ name: c.username }));
return (
<div>
<h2>Groups</h2>
<p>Assign roles to collections of clients (dynamic security groups).</p>
<p>
Assign roles to collections of clients (dynamic security groups). Each
group manages its own roles and members.
</p>
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
@@ -57,26 +122,140 @@ export function GroupsPage() {
<tr>
<th>Group</th>
<th>Roles</th>
<th>Members</th>
<th>Add</th>
<th></th>
</tr>
</thead>
<tbody>
{(groups.data ?? []).map((g) => (
<tr key={g.groupname}>
<td className="mono">{g.groupname}</td>
<td>{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"}</td>
<td>
<button
className="danger"
onClick={() => {
if (confirm(`Delete group ${g.groupname}?`)) remove.mutate(g.groupname);
}}
>
Delete
</button>
</td>
</tr>
))}
{(groups.data ?? []).map((g) => {
const groupRoles = (g.roles ?? []).map((r) => ({ name: r.rolename }));
const members = membersOf(g.groupname);
const freeRoles = roleNames.filter(
(name) => !groupRoles.some((r) => r.name === name),
);
const freeMembers = clientNames.filter(
(name) => !members.some((m) => m.name === name),
);
return (
<tr key={g.groupname}>
<td className="mono">{g.groupname}</td>
<td>
{groupRoles.length === 0 ? (
"—"
) : (
groupRoles.map((r) => (
<span
key={r.name}
className="mono"
style={{
display: "inline-block",
border: "1px solid #cbd2d9",
borderRadius: "10px",
padding: "0.05rem 0.4rem",
margin: "0.1rem",
fontSize: "0.8rem",
}}
>
{r.name}{" "}
<button
className="danger"
style={{ padding: "0 0.3rem", marginLeft: "0.15rem" }}
title={`Remove role ${r.name}`}
onClick={() =>
dropRole.mutate({ groupname: g.groupname, rolename: r.name })
}
>
×
</button>
</span>
))
)}
</td>
<td>
{members.length === 0 ? (
"—"
) : (
members.map((m) => (
<span
key={m.name}
className="mono"
style={{
display: "inline-block",
border: "1px solid #cbd2d9",
borderRadius: "10px",
padding: "0.05rem 0.4rem",
margin: "0.1rem",
fontSize: "0.8rem",
}}
>
{m.name}{" "}
<button
className="danger"
style={{ padding: "0 0.3rem", marginLeft: "0.15rem" }}
title={`Remove member ${m.name}`}
onClick={() =>
dropMember.mutate({ groupname: g.groupname, username: m.name })
}
>
×
</button>
</span>
))
)}
</td>
<td>
{freeRoles.length > 0 ? (
<select
defaultValue=""
onChange={(e) => {
if (e.target.value)
addRole.mutate({ groupname: g.groupname, rolename: e.target.value });
e.target.value = "";
}}
>
<option value="">+ role…</option>
{freeRoles.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
) : null}{" "}
{freeMembers.length > 0 ? (
<select
defaultValue=""
onChange={(e) => {
if (e.target.value)
addMember.mutate({
groupname: g.groupname,
username: e.target.value,
});
e.target.value = "";
}}
>
<option value="">+ member…</option>
{freeMembers.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
) : null}
</td>
<td>
<button
className="danger"
onClick={() => {
if (confirm(`Delete group ${g.groupname}?`)) remove.mutate(g.groupname);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+170 -74
View File
@@ -1,4 +1,4 @@
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Role, type AclRule } from "../api";
@@ -11,11 +11,28 @@ const ACL_TYPES = [
"unsubscribePattern",
] as const;
// Per-row client-side validation. A rule must be complete before Save is
// enabled — this is what makes the Save button's disabled state explain
// itself instead of silently blocking.
function aclIssue(acl: AclRule): string | null {
if (!acl.topic || acl.topic.trim() === "") {
return "topic filter is required";
}
if (acl.topic.includes("#") && !acl.topic.endsWith("#")) {
return "'#' must be the last level of the filter";
}
if (acl.topic.split("/").some((level) => level === "+#" || level === "#+")) {
return "wildcards must be the only item in a level";
}
return null;
}
export function RolesPage() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [banner, setBanner] = useState<string | null>(null);
const roles = useQuery({
queryKey: ["roles"],
@@ -28,10 +45,34 @@ export function RolesPage() {
enabled: selected !== null,
});
// ---- Draft-then-save model -------------------------------------------
// The ACL table is edited in LOCAL state; nothing hits the wire until
// Save is pressed. This fixes the original bug where "+ Add" instantly
// PUT a placeholder row with an empty topic — a 422 that left the UI
// with no row and no fields to type into.
const [draft, setDraft] = useState<AclRule[]>([]);
const [dirty, setDirty] = useState(false);
useEffect(() => {
// Re-seed the draft whenever the server data changes (initial load,
// refetch after save) — but never while the user is mid-edit.
if (!dirty && detail.data) {
setDraft(detail.data.acls ?? []);
}
}, [detail.data, dirty]);
const startEditing = (rolename: string) => {
setSelected(rolename);
setDirty(false);
setBanner(null);
setError(null);
};
const create = useMutation({
mutationFn: (rolename: string) => api.post("/mqtt/roles", { rolename, acls: [] }),
onSuccess: () => {
setName("");
setBanner("Role created. Open it to add ACL rules.");
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
onError: (e: Error) => setError(e.message),
@@ -40,8 +81,12 @@ export function RolesPage() {
const save = useMutation({
mutationFn: (role: Role) => api.put(`/mqtt/roles/${role.rolename}`, role),
onSuccess: () => {
setDirty(false);
setBanner("ACLs saved.");
setError(null);
// Refetch: the server response re-seeds the draft via useEffect.
queryClient.invalidateQueries({ queryKey: ["role", selected] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
onError: (e: Error) => setError(e.message),
});
@@ -53,26 +98,38 @@ export function RolesPage() {
const role = detail.data;
const updateAcl = (index: number, patch: Partial<AclRule>) => {
if (!role) return;
const acls = role.acls.map((a, i) => (i === index ? { ...a, ...patch } : a));
save.mutate({ ...role, acls });
const updateDraftAcl = (index: number, patch: Partial<AclRule>) => {
setDirty(true);
setDraft((rows) => rows.map((a, i) => (i === index ? { ...a, ...patch } : a)));
};
const addAcl = () => {
if (!role) return;
save.mutate({
...role,
acls: [
...role.acls,
{ acltype: "publishClientSend", topic: "", allow: true, priority: 0 },
],
});
const addDraftAcl = () => {
setDirty(true);
setDraft((rows) => [
...rows,
{ acltype: "publishClientSend", topic: "", allow: true, priority: 0 },
]);
};
const removeAcl = (index: number) => {
const removeDraftAcl = (index: number) => {
setDirty(true);
setDraft((rows) => rows.filter((_, i) => i !== index));
};
const validationIssues = draft
.map((acl, index) => ({ index, issue: aclIssue(acl) }))
.filter((entry) => entry.issue !== null);
const canSave = role !== undefined && dirty && validationIssues.length === 0;
const saveAcls = () => {
if (!role || !canSave) return;
save.mutate({ ...role, acls: draft });
};
const discard = () => {
if (!role) return;
save.mutate({ ...role, acls: role.acls.filter((_, i) => i !== index) });
setDraft(role.acls ?? []);
setDirty(false);
};
return (
@@ -85,6 +142,7 @@ export function RolesPage() {
username and client id.
</p>
{error && <div className="banner error">{error}</div>}
{banner && !error && <div className="banner ok">{banner}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="role-name">New role name</label>
@@ -104,7 +162,6 @@ export function RolesPage() {
<thead>
<tr>
<th>Role</th>
<th>ACLs</th>
<th>Open</th>
</tr>
</thead>
@@ -112,9 +169,8 @@ export function RolesPage() {
{(roles.data ?? []).map((r) => (
<tr key={r.rolename}>
<td className="mono">{r.rolename}</td>
<td>{(r.acls ?? []).length}</td>
<td>
<button onClick={() => setSelected(r.rolename)}>Edit ACLs</button>
<button onClick={() => startEditing(r.rolename)}>Edit ACLs</button>
</td>
</tr>
))}
@@ -126,9 +182,31 @@ export function RolesPage() {
<h3>
Role: <span className="mono">{role.rolename}</span>
</h3>
<button onClick={addAcl} disabled={save.isPending}>
+ Add ACL rule
</button>
<button onClick={addDraftAcl}>+ Add ACL rule</button>{" "}
<button
className="primary"
onClick={saveAcls}
disabled={!canSave || save.isPending}
title={
validationIssues.length > 0
? "Fix the highlighted rows before saving"
: dirty
? "Save all changes"
: "Nothing to save"
}
>
{save.isPending ? "Saving…" : "Save"}
</button>{" "}
{dirty && !save.isPending && (
<button onClick={discard}>Discard changes</button>
)}
{validationIssues.length > 0 && (
<div className="banner warn">
{validationIssues.length} rule
{validationIssues.length > 1 ? "s" : ""} need attention before saving.
</div>
)}
<table style={{ marginTop: "1rem" }}>
<thead>
<tr>
@@ -140,60 +218,78 @@ export function RolesPage() {
</tr>
</thead>
<tbody>
{role.acls.map((acl, index) => (
<tr key={index}>
<td>
<select
value={acl.acltype}
onChange={(e) =>
updateAcl(index, { acltype: e.target.value as (typeof ACL_TYPES)[number] })
}
>
{ACL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td>
<input
className="mono"
value={acl.topic}
placeholder="site/a/%u/telemetry/#"
onChange={(e) => updateAcl(index, { topic: e.target.value })}
/>
</td>
<td>
<select
value={acl.allow ? "allow" : "deny"}
onChange={(e) => updateAcl(index, { allow: e.target.value === "allow" })}
>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</td>
<td>
<input
type="number"
min={0}
max={100000}
value={acl.priority}
onChange={(e) =>
updateAcl(index, { priority: Number(e.target.value) })
}
/>
</td>
<td>
<button className="danger" onClick={() => removeAcl(index)}>
Remove
</button>
</td>
</tr>
))}
{draft.map((acl, index) => {
const issue = aclIssue(acl);
return (
<tr key={index}>
<td>
<select
value={acl.acltype}
onChange={(e) =>
updateDraftAcl(index, {
acltype: e.target.value as (typeof ACL_TYPES)[number],
})
}
>
{ACL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td>
<input
className="mono"
value={acl.topic}
placeholder="site/a/%u/telemetry/#"
onChange={(e) => updateDraftAcl(index, { topic: e.target.value })}
style={issue ? { borderColor: "#e5534b" } : undefined}
/>
{issue && (
<div style={{ color: "#e5534b", fontSize: "0.8rem" }}>
{issue}
</div>
)}
</td>
<td>
<select
value={acl.allow ? "allow" : "deny"}
onChange={(e) =>
updateDraftAcl(index, { allow: e.target.value === "allow" })
}
>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</td>
<td>
<input
type="number"
min={0}
max={100000}
value={acl.priority}
onChange={(e) =>
updateDraftAcl(index, { priority: Number(e.target.value) })
}
/>
</td>
<td>
<button className="danger" onClick={() => removeDraftAcl(index)}>
Remove
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{save.isPending && <p>Saving…</p>}
{draft.length === 0 && (
<p style={{ color: "#52606d" }}>
No ACL rules yet. Use “+ Add ACL rule” — rows are editable
immediately and are only sent to the broker on Save.
</p>
)}
</section>
)}
</div>