Files
mqtt-broker-stack/frontend/src/pages/Groups.tsx
T
2026-09-17 06:23:29 +02:00

263 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
api,
assignGroupMember,
assignGroupRole,
removeGroupMember,
removeGroupRole,
type Group,
type MqttClient,
type Role,
} from "../api";
export function GroupsPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const groups = useQuery({
queryKey: ["groups"],
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("");
refresh();
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`),
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),
});
const submit = (e: FormEvent) => {
e.preventDefault();
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). Each
group manages its own roles and members.
</p>
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="group-name">New group name</label>
<input
id="group-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. sensors"
/>
<p />
<button className="primary" disabled={!name || create.isPending}>
Create group
</button>
</form>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Group</th>
<th>Roles</th>
<th>Members</th>
<th>Add</th>
<th></th>
</tr>
</thead>
<tbody>
{(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>
);
}