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
+16
View File
@@ -0,0 +1,16 @@
# Build stage (spec section 3.3: no privileged credentials in the frontend;
# VITE_API_BASE_URL is the ONLY build arg and contains no secret).
FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# Serve stage: nginx proxies /api to the backend over frontend-net.
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MQTT Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA routing: serve index.html for any non-API, non-asset path.
location / {
try_files $uri $uri/ /index.html;
}
# REST API proxy (spec section 4: frontend -> backend over frontend-net).
location /api/ {
proxy_pass http://mqtt-backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "mqtt-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "./auth";
import { LoginPage } from "./pages/Login";
import { ClientsPage } from "./pages/Clients";
import { RolesPage } from "./pages/Roles";
import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig";
function Layout({ children }: { children: React.ReactNode }) {
const { session, logout } = useAuth();
return (
<div className="layout">
<aside className="sidebar">
<h1>MQTT Management</h1>
<nav>
<NavLink to="/clients">Clients</NavLink>
<NavLink to="/groups">Groups</NavLink>
<NavLink to="/roles">Roles &amp; ACLs</NavLink>
<NavLink to="/broker">Broker configuration</NavLink>
</nav>
<p style={{ marginTop: "2rem", fontSize: "0.8rem", opacity: 0.7 }}>
{session?.username} ({session?.role})
</p>
<button onClick={() => void logout()}>Sign out</button>
</aside>
<main className="content">{children}</main>
</div>
);
}
function Protected({ children }: { children: React.ReactNode }) {
const { session, loading } = useAuth();
if (loading) return <p>Loading…</p>;
if (!session) return <Navigate to="/login" replace />;
return <Layout>{children}</Layout>;
}
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/clients"
element={
<Protected>
<ClientsPage />
</Protected>
}
/>
<Route
path="/groups"
element={
<Protected>
<GroupsPage />
</Protected>
}
/>
<Route
path="/roles"
element={
<Protected>
<RolesPage />
</Protected>
}
/>
<Route
path="/broker"
element={
<Protected>
<BrokerConfigPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} />
</Routes>
);
}
+116
View File
@@ -0,0 +1,116 @@
// Thin API client around fetch with session cookies (spec section 8:
// HttpOnly cookies, never tokens in localStorage).
const BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
if (!response.ok) {
let message = response.statusText;
try {
const body = await response.json();
message = body.detail ?? body.error ?? message;
} catch {
/* non-JSON error body */
}
throw new ApiError(response.status, message);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "PUT", body: body === undefined ? undefined : JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "PATCH", body: body === undefined ? undefined : JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
// --- Types (mirrors backend schemas) ---------------------------------------
export interface SessionInfo {
username: string;
role: "super_admin" | "broker_admin" | "pki_admin" | "operator" | "auditor";
must_change_password: boolean;
}
export interface AclRule {
acltype: string;
topic: string;
allow: boolean;
priority: number;
}
export interface Role {
rolename: string;
text_name?: string | null;
text_description?: string | null;
acls: AclRule[];
}
export interface Group {
groupname: string;
text_name?: string | null;
roles: { rolename: string; priority: number }[];
}
export interface MqttClient {
username: string;
clientid: string | null;
text_name: string | null;
text_description: string | null;
enabled: boolean;
groups: { groupname: string; priority: number }[];
roles: { rolename: string; priority: number }[];
owner: string | null;
purpose: string | null;
tags: string[];
last_known_connection: string | null;
}
export interface PasswordReset {
username: string;
password: string;
}
export interface Listener {
id: string;
name: string;
enabled: boolean;
port: number;
bind_address: string | null;
protocol: "mqtt" | "websockets";
allow_anonymous: boolean;
max_connections: number;
tls_enabled: boolean;
tls_min_version: string | null;
require_certificate: boolean;
use_identity_as_username: boolean;
}
export interface Revision {
id: string;
revision_number: number;
created_by: string | null;
comment: string | null;
deployed: boolean;
deployed_at: string | null;
created_at: string;
}
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { api, type SessionInfo } from "./api";
interface AuthContextValue {
session: SessionInfo | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<SessionInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get<SessionInfo>("/auth/session")
.then(setSession)
.catch(() => setSession(null))
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (username: string, password: string) => {
setSession(await api.post<SessionInfo>("/auth/login", { username, password }));
}, []);
const logout = useCallback(async () => {
await api.post("/auth/logout");
setSession(null);
}, []);
return (
<AuthContext.Provider value={{ session, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth";
import { App } from "./App";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
+129
View File
@@ -0,0 +1,129 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { api, type Listener, type Revision } from "../api";
interface ConfigPreview {
config_text: string;
warnings: string[];
}
export function BrokerConfigPage() {
const listeners = useQuery({
queryKey: ["listeners"],
queryFn: () => api.get<Listener[]>("/broker/listeners"),
});
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 deploy = useMutation({
mutationFn: (comment: string | null) =>
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
"/broker/config/deploy",
{ comment },
),
onSuccess: () => {
preview.refetch();
revisions.refetch();
},
});
return (
<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
deployment creates a revision.
</p>
<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) => (
<tr key={l.id}>
<td>{l.name}</td>
<td className="mono">{l.port}</td>
<td className="mono">{l.bind_address ?? "0.0.0.0"}</td>
<td>{l.protocol}</td>
<td>{l.allow_anonymous ? "yes" : "no"}</td>
<td>{l.enabled ? "yes" : "no"}</td>
</tr>
))}
</tbody>
</table>
)}
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
{preview.data?.warnings.map((w) => (
<div key={w} className="banner warn">
{w}
</div>
))}
<pre className="config mono">{preview.data?.config_text ?? "Generating…"}</pre>
<button
className="primary"
disabled={deploy.isPending}
onClick={() => {
const comment = prompt("Deployment comment (optional)");
deploy.mutate(comment);
}}
>
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
</button>
{deploy.data && (
<div className="banner ok">
Deployed revision {deploy.data.revision}. Broker health:{" "}
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
</div>
)}
{deploy.error && (
<div className="banner error">{(deploy.error as Error).message}</div>
)}
<h3 style={{ marginTop: "2rem" }}>Revisions</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Deployed</th>
<th>By</th>
<th>Comment</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{(revisions.data ?? []).map((r) => (
<tr key={r.id}>
<td className="mono">{r.revision_number}</td>
<td>{r.deployed ? new Date(r.deployed_at!).toLocaleString() : "—"}</td>
<td>{r.created_by ?? "—"}</td>
<td>{r.comment ?? "—"}</td>
<td>{new Date(r.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Group } from "../api";
export function GroupsPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const groups = useQuery({
queryKey: ["groups"],
queryFn: () => api.get<Group[]>("/mqtt/groups"),
});
const create = useMutation({
mutationFn: (groupname: string) => api.post("/mqtt/groups", { groupname }),
onSuccess: () => {
setName("");
queryClient.invalidateQueries({ queryKey: ["groups"] });
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["groups"] }),
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (name) create.mutate(name);
};
return (
<div>
<h2>Groups</h2>
<p>Assign roles to collections of clients (dynamic security groups).</p>
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="group-name">New group name</label>
<input
id="group-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. sensors"
/>
<p />
<button className="primary" disabled={!name || create.isPending}>
Create group
</button>
</form>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Group</th>
<th>Roles</th>
<th></th>
</tr>
</thead>
<tbody>
{(groups.data ?? []).map((g) => (
<tr key={g.groupname}>
<td className="mono">{g.groupname}</td>
<td>{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"}</td>
<td>
<button
className="danger"
onClick={() => {
if (confirm(`Delete group ${g.groupname}?`)) remove.mutate(g.groupname);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { FormEvent, useState } from "react";
import { useAuth } from "../auth";
export function LoginPage() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(username, password);
} catch {
setError("Invalid username or password.");
} finally {
setBusy(false);
}
};
return (
<form className="login-card" onSubmit={(e) => void submit(e)}>
<h2>MQTT Management</h2>
{error && <div className="banner error">{error}</div>}
<label htmlFor="username">Username</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<p />
<button className="primary" disabled={busy || !username || !password}>
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Role, type AclRule } from "../api";
const ACL_TYPES = [
"publishClientSend",
"publishClientReceive",
"subscribeLiteral",
"subscribePattern",
"unsubscribeLiteral",
"unsubscribePattern",
] as const;
export function RolesPage() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const roles = useQuery({
queryKey: ["roles"],
queryFn: () => api.get<Role[]>("/mqtt/roles"),
});
const detail = useQuery({
queryKey: ["role", selected],
queryFn: () => api.get<Role>(`/mqtt/roles/${selected}`),
enabled: selected !== null,
});
const create = useMutation({
mutationFn: (rolename: string) => api.post("/mqtt/roles", { rolename, acls: [] }),
onSuccess: () => {
setName("");
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
onError: (e: Error) => setError(e.message),
});
const save = useMutation({
mutationFn: (role: Role) => api.put(`/mqtt/roles/${role.rolename}`, role),
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: ["role", selected] });
},
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (name) create.mutate(name);
};
const role = detail.data;
const updateAcl = (index: number, patch: Partial<AclRule>) => {
if (!role) return;
const acls = role.acls.map((a, i) => (i === index ? { ...a, ...patch } : a));
save.mutate({ ...role, acls });
};
const addAcl = () => {
if (!role) return;
save.mutate({
...role,
acls: [
...role.acls,
{ acltype: "publishClientSend", topic: "", allow: true, priority: 0 },
],
});
};
const removeAcl = (index: number) => {
if (!role) return;
save.mutate({ ...role, acls: role.acls.filter((_, i) => i !== index) });
};
return (
<div>
<h2>Roles &amp; ACLs</h2>
<p>
ACL types map to dynamic security: publishClientSend (client publishes),
publishClientReceive (client receives), subscribe/unsubscribe in literal
and pattern variants. <code>%u</code> and <code>%c</code> substitute the
username and client id.
</p>
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="role-name">New role name</label>
<input
id="role-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. site-a-sensors"
/>
<p />
<button className="primary" disabled={!name || create.isPending}>
Create role
</button>
</form>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Role</th>
<th>ACLs</th>
<th>Open</th>
</tr>
</thead>
<tbody>
{(roles.data ?? []).map((r) => (
<tr key={r.rolename}>
<td className="mono">{r.rolename}</td>
<td>{(r.acls ?? []).length}</td>
<td>
<button onClick={() => setSelected(r.rolename)}>Edit ACLs</button>
</td>
</tr>
))}
</tbody>
</table>
{role && (
<section style={{ marginTop: "2rem" }}>
<h3>
Role: <span className="mono">{role.rolename}</span>
</h3>
<button onClick={addAcl} disabled={save.isPending}>
+ Add ACL rule
</button>
<table style={{ marginTop: "1rem" }}>
<thead>
<tr>
<th>Type</th>
<th>Topic filter</th>
<th>Effect</th>
<th>Priority</th>
<th></th>
</tr>
</thead>
<tbody>
{role.acls.map((acl, index) => (
<tr key={index}>
<td>
<select
value={acl.acltype}
onChange={(e) =>
updateAcl(index, { acltype: e.target.value as (typeof ACL_TYPES)[number] })
}
>
{ACL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td>
<input
className="mono"
value={acl.topic}
placeholder="site/a/%u/telemetry/#"
onChange={(e) => updateAcl(index, { topic: e.target.value })}
/>
</td>
<td>
<select
value={acl.allow ? "allow" : "deny"}
onChange={(e) => updateAcl(index, { allow: e.target.value === "allow" })}
>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</td>
<td>
<input
type="number"
min={0}
max={100000}
value={acl.priority}
onChange={(e) =>
updateAcl(index, { priority: Number(e.target.value) })
}
/>
</td>
<td>
<button className="danger" onClick={() => removeAcl(index)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
{save.isPending && <p>Saving…</p>}
</section>
)}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
:root {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f4f5f7;
color: #1f2933;
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.sidebar {
background: #1f2933;
color: #f0f4f8;
padding: 1.25rem;
}
.sidebar h1 {
font-size: 1.05rem;
margin: 0 0 1.5rem;
}
.sidebar nav a {
display: block;
padding: 0.45rem 0.6rem;
margin: 0.1rem 0;
border-radius: 6px;
color: #cbd2d9;
text-decoration: none;
}
.sidebar nav a.active,
.sidebar nav a:hover {
background: #323f4b;
color: #fff;
}
.content {
padding: 1.5rem 2rem;
max-width: 1100px;
}
.content h2 {
margin-top: 0;
}
table {
border-collapse: collapse;
width: 100%;
background: #fff;
border-radius: 8px;
overflow: hidden;
}
th,
td {
text-align: left;
padding: 0.55rem 0.8rem;
border-bottom: 1px solid #e4e7eb;
font-size: 0.92rem;
}
th {
background: #f0f4f8;
}
button {
font: inherit;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid #cbd2d9;
background: #fff;
cursor: pointer;
}
button.primary {
background: #1f6feb;
border-color: #1f6feb;
color: #fff;
}
button.danger {
background: #fff;
border-color: #e5534b;
color: #e5534b;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
form.stack label,
label {
display: block;
margin: 0.6rem 0 0.2rem;
font-size: 0.85rem;
color: #52606d;
}
input,
select,
textarea {
font: inherit;
padding: 0.45rem 0.55rem;
border: 1px solid #cbd2d9;
border-radius: 6px;
width: 100%;
background: #fff;
}
.login-card {
max-width: 340px;
margin: 12vh auto;
background: #fff;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(31, 41, 51, 0.12);
}
.banner {
padding: 0.6rem 0.9rem;
border-radius: 6px;
margin: 0.6rem 0;
}
.banner.ok {
background: #e6f4ea;
color: #1e6b3a;
}
.banner.error {
background: #fdecea;
color: #a4342d;
}
.banner.warn {
background: #fff6e0;
color: #8a5a00;
}
.mono {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 0.85rem;
}
pre.config {
background: #1f2933;
color: #d5e3f0;
padding: 1rem;
border-radius: 8px;
overflow: auto;
max-height: 420px;
}
.password-once {
background: #fff6e0;
border: 1px solid #e3b341;
padding: 0.8rem 1rem;
border-radius: 8px;
font-family: ui-monospace, monospace;
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
// Typed access to the single non-secret frontend build arg (spec §7.2).
// Browser-visible env must NEVER contain passwords or secrets.
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Local dev: Vite on :5173 proxies API calls to the backend container.
"/api": "http://localhost:8000",
},
},
});