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
+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>