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

287 lines
9.1 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,
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();
const [newUsername, setNewUsername] = useState("");
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
const [error, setError] = useState<string | null>(null);
const clients = useQuery({
queryKey: ["clients"],
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("");
refresh();
},
onError: (e: Error) => setError(e.message),
});
const disable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
onSuccess: refresh,
});
const enable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`),
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`),
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),
});
const submit = (e: FormEvent) => {
e.preventDefault();
setError(null);
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. Use the selectors to assign
roles or group memberships directly.
</p>
{oncePassword && (
<div className="banner warn">
<strong>Password for {oncePassword.username} — copy it now, it is shown
only once:</strong>
<div className="password-once mono">{oncePassword.password}</div>
</div>
)}
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="new-client">New client username</label>
<input
id="new-client"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="e.g. ps042"
/>
<p />
<button className="primary" disabled={!newUsername || create.isPending}>
{create.isPending ? "Creating…" : "Create client (random password)"}
</button>
</form>
{clients.isLoading ? (
<p>Loading clients…</p>
) : (
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Username</th>
<th>Client ID</th>
<th>Enabled</th>
<th>Roles</th>
<th>Groups</th>
<th>Assign</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(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>
)}
</div>
);
}