This commit is contained in:
Jonas Bengtsson
2026-09-16 06:08:37 +02:00
parent cce6a4960e
commit 8d2c54beae
54 changed files with 8480 additions and 41 deletions
+125
View File
@@ -0,0 +1,125 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type MqttClient, type PasswordReset } from "../api";
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"),
});
const create = useMutation({
mutationFn: (username: string) =>
api.post<PasswordReset>("/mqtt/clients", { username }),
onSuccess: (result) => {
setOncePassword(result);
setNewUsername("");
queryClient.invalidateQueries({ queryKey: ["clients"] });
},
onError: (e: Error) => setError(e.message),
});
const disable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
});
const enable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
});
const remove = useMutation({
mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (newUsername) create.mutate(newUsername);
};
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.
</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>Groups</th>
<th>Roles</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>
))}
</tbody>
</table>
)}
</div>
);
}