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