263 lines
8.7 KiB
TypeScript
263 lines
8.7 KiB
TypeScript
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>
|
||
);
|
||
} |