🚧 WIP
This commit is contained in:
+194
-32
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user