🚧 WIP
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./auth";
|
||||
import { LoginPage } from "./pages/Login";
|
||||
import { ClientsPage } from "./pages/Clients";
|
||||
@@ -30,8 +30,12 @@ function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function Protected({ children }: { children: React.ReactNode }) {
|
||||
const { session, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
if (loading) return <p>Loading…</p>;
|
||||
if (!session) return <Navigate to="/login" replace />;
|
||||
if (!session) {
|
||||
// Remember the guarded route so Login can return here after sign-in.
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,24 @@ function errorMessage(body: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A 401 here means the session cookie expired or was never valid.
|
||||
// api.ts lives outside the React router, so the only way to the login
|
||||
// screen is a full-page navigation; the current page is remembered via
|
||||
// ?next= so Login can return after sign-in.
|
||||
// Excluded: the login attempt itself (a 401 there is bad credentials and
|
||||
// must render the error banner, not navigate away), the startup session
|
||||
// probe (a 401 there is the normal "not signed in" state, which
|
||||
// <Protected> turns into an in-app redirect instead), and logout (a 401
|
||||
// there just means the session already expired — auth.tsx clears the
|
||||
// client session anyway, so no redirect is needed).
|
||||
const EXPECTED_401_PATHS = new Set(["/auth/login", "/auth/session", "/auth/logout"]);
|
||||
|
||||
function redirectToLogin(): void {
|
||||
const current = window.location.pathname;
|
||||
if (current === "/login") return; // defensive: never reload the login page
|
||||
window.location.replace(`/login?next=${encodeURIComponent(current)}`);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
credentials: "include",
|
||||
@@ -55,6 +73,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
if (response.status === 401 && !EXPECTED_401_PATHS.has(path)) {
|
||||
redirectToLogin();
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
@@ -34,7 +34,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.post("/auth/logout");
|
||||
// A 401 here means the session already expired server-side; clearing
|
||||
// the client session is still the right outcome (api.ts does not
|
||||
// redirect for /auth/logout), and <Protected> then shows /login.
|
||||
await api.post("/auth/logout").catch(() => undefined);
|
||||
setSession(null);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Listener, type Revision } from "../api";
|
||||
|
||||
interface ConfigPreview {
|
||||
@@ -6,22 +7,80 @@ interface ConfigPreview {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface BrokerSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
value_type: "string" | "int" | "bool";
|
||||
}
|
||||
|
||||
const EMPTY_LISTENER: Omit<Listener, "id"> = {
|
||||
name: "",
|
||||
enabled: true,
|
||||
port: 1883,
|
||||
bind_address: null,
|
||||
protocol: "mqtt",
|
||||
allow_anonymous: false,
|
||||
max_connections: -1,
|
||||
tls_enabled: false,
|
||||
tls_min_version: null,
|
||||
require_certificate: false,
|
||||
use_identity_as_username: false,
|
||||
};
|
||||
|
||||
// Curated subset of spec section 31; anything else can be added later
|
||||
// through the same settings endpoint (advanced editor is a later release).
|
||||
const COMMON_SETTINGS: { key: string; label: string; placeholder: string; hint: string }[] = [
|
||||
{ key: "max_packet_size", label: "max_packet_size", placeholder: "2000000", hint: "bytes; 2.1 default is 2 MB" },
|
||||
{ key: "autosave_interval", label: "autosave_interval", placeholder: "900", hint: "seconds between persistence saves" },
|
||||
{ key: "sys_interval", label: "sys_interval", placeholder: "10", hint: "$SYS update interval (s)" },
|
||||
{ key: "message_size_limit", label: "message_size_limit", placeholder: "0", hint: "max payload bytes; 0 = unlimited" },
|
||||
{ key: "max_queued_messages", label: "max_queued_messages", placeholder: "1000", hint: "per-client queue for offline clients" },
|
||||
{ key: "global_max_clients", label: "global_max_clients", placeholder: "-1", hint: "total sessions; -1 = unlimited" },
|
||||
];
|
||||
|
||||
export function BrokerConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listeners"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["config-preview"] });
|
||||
};
|
||||
|
||||
const listeners = useQuery({
|
||||
queryKey: ["listeners"],
|
||||
queryFn: () => api.get<Listener[]>("/broker/listeners"),
|
||||
});
|
||||
|
||||
const settings = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => api.get<BrokerSetting[]>("/broker/settings"),
|
||||
});
|
||||
const preview = useQuery({
|
||||
queryKey: ["config-preview"],
|
||||
queryFn: () => api.get<ConfigPreview>("/broker/config/preview"),
|
||||
});
|
||||
|
||||
const revisions = useQuery({
|
||||
queryKey: ["revisions"],
|
||||
queryFn: () => api.get<Revision[]>("/broker/revisions"),
|
||||
});
|
||||
|
||||
const addListener = useMutation({
|
||||
mutationFn: (l: Omit<Listener, "id">) => api.post<Listener>("/broker/listeners", l),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const updateListener = useMutation({
|
||||
mutationFn: ({ id, ...l }: Listener) => api.put<Listener>(`/broker/listeners/${id}`, l),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const removeListener = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/broker/listeners/${id}`),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const putSetting = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
api.put(`/broker/settings/${key}`, { key, value, value_type: "string" }),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const deploy = useMutation({
|
||||
mutationFn: (comment: string | null) =>
|
||||
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
|
||||
@@ -29,8 +88,8 @@ export function BrokerConfigPage() {
|
||||
{ comment },
|
||||
),
|
||||
onSuccess: () => {
|
||||
preview.refetch();
|
||||
revisions.refetch();
|
||||
refresh();
|
||||
queryClient.invalidateQueries({ queryKey: ["revisions"] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,28 +97,52 @@ export function BrokerConfigPage() {
|
||||
<div>
|
||||
<h2>Broker configuration</h2>
|
||||
<p>
|
||||
The configuration is generated from the platform model, validated with
|
||||
<code> mosquitto --test-config</code>, deployed atomically, and every
|
||||
The configuration is generated from the model below, validated with{" "}
|
||||
<code>mosquitto --test-config</code>, deployed atomically, and every
|
||||
deployment creates a revision.
|
||||
</p>
|
||||
{(listeners.error || preview.error || settings.error) && (
|
||||
<div className="banner error">
|
||||
{((listeners.error ?? preview.error ?? settings.error) as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3>Listeners</h3>
|
||||
{listeners.isLoading ? (
|
||||
<p>Loading…</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Port</th>
|
||||
<th>Bind</th>
|
||||
<th>Protocol</th>
|
||||
<th>Anonymous</th>
|
||||
<th>Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(listeners.data ?? []).map((l) => (
|
||||
<h3 style={{ marginTop: "1.5rem" }}>Add listener</h3>
|
||||
<ListenerForm onSubmit={(l) => addListener.mutate(l)} pending={addListener.isPending} />
|
||||
{addListener.error && (
|
||||
<div className="banner error">{(addListener.error as Error).message}</div>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: "2rem" }}>Listeners</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Port</th>
|
||||
<th>Bind</th>
|
||||
<th>Protocol</th>
|
||||
<th>Anonymous</th>
|
||||
<th>Enabled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(listeners.data ?? []).map((l) =>
|
||||
editingId === l.id ? (
|
||||
<tr key={l.id}>
|
||||
<td colSpan={7}>
|
||||
<ListenerForm
|
||||
initial={l}
|
||||
pending={updateListener.isPending}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onSubmit={(patch) => {
|
||||
updateListener.mutate({ ...l, ...patch });
|
||||
setEditingId(null);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={l.id}>
|
||||
<td>{l.name}</td>
|
||||
<td className="mono">{l.port}</td>
|
||||
@@ -67,12 +150,59 @@ export function BrokerConfigPage() {
|
||||
<td>{l.protocol}</td>
|
||||
<td>{l.allow_anonymous ? "yes" : "no"}</td>
|
||||
<td>{l.enabled ? "yes" : "no"}</td>
|
||||
<td>
|
||||
<button onClick={() => setEditingId(l.id)}>Edit</button>{" "}
|
||||
<button onClick={() => updateListener.mutate({ ...l, enabled: !l.enabled })}>
|
||||
{l.enabled ? "Disable" : "Enable"}
|
||||
</button>{" "}
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete listener ${l.name}?`)) removeListener.mutate(l.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{(listeners.data ?? []).length === 0 && !listeners.isLoading && (
|
||||
<p style={{ color: "#52606d" }}>
|
||||
No listeners yet. Add one above — after a deploy, the broker is only
|
||||
reachable through the listeners defined here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: "2rem" }}>Global settings</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Setting</th>
|
||||
<th>Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMMON_SETTINGS.map((s) => {
|
||||
const existing = (settings.data ?? []).find((row) => row.key === s.key);
|
||||
return (
|
||||
<SettingsRow
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
hint={s.hint}
|
||||
placeholder={s.placeholder}
|
||||
initial={existing?.value}
|
||||
pending={putSetting.isPending}
|
||||
onSave={(value) => putSetting.mutate({ key: s.key, value })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
|
||||
{preview.data?.warnings.map((w) => (
|
||||
<div key={w} className="banner warn">
|
||||
@@ -126,4 +256,148 @@ export function BrokerConfigPage() {
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListenerForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
pending,
|
||||
}: {
|
||||
initial?: Listener;
|
||||
onSubmit: (l: Omit<Listener, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Omit<Listener, "id">>(initial ?? EMPTY_LISTENER);
|
||||
useEffect(() => {
|
||||
setDraft(initial ?? EMPTY_LISTENER);
|
||||
}, [initial]);
|
||||
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
const valid = draft.name.trim() !== "" && draft.port > 0 && draft.port <= 65535;
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (valid) {
|
||||
onSubmit(draft);
|
||||
if (!initial) setDraft(EMPTY_LISTENER);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={submit}>
|
||||
{initial && (
|
||||
<div className="banner warn">
|
||||
Port or bind changes require a broker restart — a config reload
|
||||
cannot modify existing listener definitions.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<label>Name</label>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder="Production MQTT"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.port}
|
||||
onChange={(e) => set({ port: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Protocol</label>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(e) => set({ protocol: e.target.value as "mqtt" | "websockets" })}
|
||||
>
|
||||
<option value="mqtt">mqtt</option>
|
||||
<option value="websockets">websockets</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Bind address</label>
|
||||
<input
|
||||
value={draft.bind_address ?? ""}
|
||||
onChange={(e) => set({ bind_address: e.target.value || null })}
|
||||
placeholder="all interfaces"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Allow anonymous</label>
|
||||
<select
|
||||
value={draft.allow_anonymous ? "true" : "false"}
|
||||
onChange={(e) => set({ allow_anonymous: e.target.value === "true" })}
|
||||
>
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Max connections</label>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.max_connections}
|
||||
onChange={(e) => set({ max_connections: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p />
|
||||
<button className="primary" disabled={!valid || pending}>
|
||||
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
|
||||
</button>{" "}
|
||||
{initial && onCancel && (
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({
|
||||
label,
|
||||
hint,
|
||||
placeholder,
|
||||
initial,
|
||||
pending,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
placeholder: string;
|
||||
initial?: string;
|
||||
pending: boolean;
|
||||
onSave: (value: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial ?? "");
|
||||
useEffect(() => {
|
||||
setValue(initial ?? "");
|
||||
}, [initial]);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<span className="mono">{label}</span>
|
||||
<div style={{ color: "#52606d", fontSize: "0.75rem" }}>{hint}</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
style={{ maxWidth: "220px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button disabled={pending || value === (initial ?? "")} onClick={() => onSave(value)}>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,41 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
// Only same-app targets are allowed: blocks open redirects such as
|
||||
// "//evil.host" or "https://evil.host" via the ?next= query parameter.
|
||||
function safeTarget(candidate: string | null): string {
|
||||
return candidate && candidate.startsWith("/") && !candidate.startsWith("//")
|
||||
? candidate
|
||||
: "/clients";
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Where to go after sign-in. <Protected> passes the guarded route via
|
||||
// router state (in-app navigation); the 401 handler in api.ts passes the
|
||||
// current page as ?next= (full-page navigation). Direct /login visits
|
||||
// default to /clients.
|
||||
const from = safeTarget(
|
||||
(location.state as { from?: string } | null)?.from ?? searchParams.get("next"),
|
||||
);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username, password);
|
||||
// replace: Back must not return to the login screen after sign-in.
|
||||
navigate(from, { replace: true });
|
||||
} catch {
|
||||
setError("Invalid username or password.");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user