312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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 resetPassword = useMutation({
|
||
mutationFn: (username: string) =>
|
||
api.post<PasswordReset>(`/mqtt/clients/${username}/password:reset`),
|
||
onSuccess: (result) => setOncePassword(result),
|
||
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>}
|
||
{(clients.error || roles.error || groups.error) && (
|
||
<div className="banner error">
|
||
Failed to load clients/roles/groups from the broker management API:{" "}
|
||
{((clients.error ?? roles.error ?? groups.error) as Error).message}
|
||
</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
|
||
onClick={() => {
|
||
if (
|
||
confirm(
|
||
`Reset password for ${c.username}? The current password becomes invalid immediately.`,
|
||
)
|
||
)
|
||
resetPassword.mutate(c.username);
|
||
}}
|
||
>
|
||
Reset password
|
||
</button>{" "}
|
||
<button
|
||
className="danger"
|
||
onClick={() => {
|
||
if (confirm(`Delete client ${c.username}?`))
|
||
remove.mutate(c.username);
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
);
|
||
} |