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
+194 -32
View File
@@ -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>
)}