docs: update README with desktop requirements, helper builds, and realistic MVP usage notes

Expand README with desktop platform requirements (Windows x86, macOS ARM), helper build commands, gateway utility scripts, and updated local test flow. Add realistic MVP usage section clarifying current platform build status, gateway configuration needs, and admin debug profile behavior with client private key handling.
This commit is contained in:
2026-03-16 06:30:08 +01:00
parent 7c4bba1021
commit 6ec5133773
32 changed files with 1076 additions and 49 deletions

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
@@ -13,12 +13,27 @@ const columns = [
];
export function DevicesPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["devices"],
queryFn: api.devices
});
const profileQuery = useQuery({
queryKey: ["device-profile", query.data?.[0]?.id ?? ""],
queryFn: () => api.deviceProfile(query.data?.[0]?.id ?? ""),
enabled: Boolean(query.data?.[0]?.id)
});
const revokeMutation = useMutation({
mutationFn: api.revokeDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
});
const rotateMutation = useMutation({
mutationFn: api.rotateDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
});
const rows = query.data?.map((device) => ({
id: device.id,
name: device.name,
owner: device.user_id ?? "assigned user",
platform: device.platform,
@@ -34,6 +49,23 @@ export function DevicesPage() {
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
{rows.length > 0 ? (
<div className="grid two">
<div className="card">
<h4>Selected device profile</h4>
<p className="notice">Admin view shows a debug template. The client private key stays on the device.</p>
<pre className="code-block">{profileQuery.data?.profile.content ?? "Loading profile..."}</pre>
</div>
<div className="card">
<h4>Device actions</h4>
<p>Target: {rows[0].name}</p>
<div className="action-row">
<button className="button" onClick={() => rotateMutation.mutate(rows[0].id)}>Rotate profile</button>
<button className="ghost-button" onClick={() => revokeMutation.mutate(rows[0].id)}>Revoke device</button>
</div>
</div>
</div>
) : null}
</Page>
);
}

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
@@ -12,10 +13,31 @@ const columns = [
];
export function GatewaysPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["gateways"],
queryFn: api.gateways
});
const syncQuery = useQuery({
queryKey: ["gateway-sync", query.data?.[0]?.id ?? ""],
queryFn: () => api.gatewaySync(query.data?.[0]?.id ?? ""),
enabled: Boolean(query.data?.[0]?.id)
});
const [form, setForm] = useState({
endpoint: "",
public_key: "",
listen_port: 51820,
vpn_cidr: "100.96.0.0/24",
dns_servers: "10.20.0.53",
is_active: true
});
const updateMutation = useMutation({
mutationFn: (payload: Parameters<typeof api.updateGateway>[1]) => api.updateGateway(query.data?.[0]?.id ?? "", payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["gateways"] });
void queryClient.invalidateQueries({ queryKey: ["gateway-sync"] });
}
});
const rows = query.data?.map((gateway) => ({
name: gateway.name,
@@ -24,6 +46,32 @@ export function GatewaysPage() {
status: gateway.is_active ? "active" : "inactive"
})) ?? [];
useEffect(() => {
if (query.data?.[0]) {
const gateway = query.data[0];
setForm({
endpoint: gateway.endpoint,
public_key: gateway.public_key,
listen_port: gateway.listen_port,
vpn_cidr: gateway.vpn_cidr,
dns_servers: gateway.dns_servers.join(", "),
is_active: gateway.is_active
});
}
}, [query.data]);
function onSubmit(event: FormEvent) {
event.preventDefault();
updateMutation.mutate({
endpoint: form.endpoint,
public_key: form.public_key,
listen_port: Number(form.listen_port),
vpn_cidr: form.vpn_cidr,
dns_servers: form.dns_servers.split(",").map((value) => value.trim()).filter(Boolean),
is_active: form.is_active
});
}
return (
<Page title="Gateways" subtitle="Track WireGuard endpoints and sync state.">
{query.isError ? <p className="notice">Unable to load gateways from the API.</p> : null}
@@ -32,6 +80,24 @@ export function GatewaysPage() {
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
<form className="inline-form" onSubmit={onSubmit}>
<input placeholder="endpoint" value={form.endpoint} onChange={(event) => setForm((value) => ({ ...value, endpoint: event.target.value }))} />
<input placeholder="public key" value={form.public_key} onChange={(event) => setForm((value) => ({ ...value, public_key: event.target.value }))} />
<input placeholder="listen port" type="number" value={form.listen_port} onChange={(event) => setForm((value) => ({ ...value, listen_port: Number(event.target.value) }))} />
<input placeholder="vpn cidr" value={form.vpn_cidr} onChange={(event) => setForm((value) => ({ ...value, vpn_cidr: event.target.value }))} />
<input placeholder="dns servers" value={form.dns_servers} onChange={(event) => setForm((value) => ({ ...value, dns_servers: event.target.value }))} />
<label className="checkbox">
<input type="checkbox" checked={form.is_active} onChange={(event) => setForm((value) => ({ ...value, is_active: event.target.checked }))} />
Active
</label>
<button className="button" type="submit" disabled={updateMutation.isPending}>Update gateway</button>
</form>
{syncQuery.data ? (
<div className="card">
<h4>Sync bundle preview</h4>
<pre className="code-block">{JSON.stringify(syncQuery.data, null, 2)}</pre>
</div>
) : null}
</Page>
);
}

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { FormEvent, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
@@ -12,10 +13,30 @@ const columns = [
];
export function PoliciesPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["policies"],
queryFn: api.policies
});
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: api.users
});
const [form, setForm] = useState({
name: "",
description: "",
destinations: "",
targetUserId: "",
fullTunnel: false
});
const createMutation = useMutation({
mutationFn: api.createPolicy,
onSuccess: () => {
setForm({ name: "", description: "", destinations: "", targetUserId: "", fullTunnel: false });
void queryClient.invalidateQueries({ queryKey: ["policies"] });
}
});
const rows = query.data?.map((policy) => ({
name: policy.name,
@@ -24,13 +45,55 @@ export function PoliciesPage() {
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
})) ?? [];
const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
function onSubmit(event: FormEvent) {
event.preventDefault();
if (!form.targetUserId) {
return;
}
createMutation.mutate({
name: form.name,
description: form.description,
priority: 100,
effect: "allow",
full_tunnel: form.fullTunnel,
destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean),
targets: [{ type: "user", id: form.targetUserId }]
});
}
return (
<Page
title="Policies"
subtitle="Manage per-user and per-device access controls enforced at the gateway."
actions={<button className="button">New policy</button>}
actions={<span className="pill">Gateway enforced</span>}
>
<form className="inline-form" onSubmit={onSubmit}>
<input placeholder="policy name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
<input
placeholder="destinations: 172.16.10.0/24,10.0.0.5/32"
value={form.destinations}
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={form.fullTunnel}
/>
<select value={form.targetUserId} onChange={(event) => setForm((value) => ({ ...value, targetUserId: event.target.value }))}>
<option value="">target user</option>
{selectableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
<label className="checkbox">
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
</label>
<button className="button" type="submit" disabled={createMutation.isPending}>Create policy</button>
</form>
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create policy.</p> : null}
<Table
columns={columns}
rows={rows}

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
@@ -12,10 +13,26 @@ const columns = [
];
export function UsersPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["users"],
queryFn: api.users
});
const [form, setForm] = useState({
username: "",
display_name: "",
email: "",
password: "",
role: "user"
});
const createMutation = useMutation({
mutationFn: api.createUser,
onSuccess: () => {
setForm({ username: "", display_name: "", email: "", password: "", role: "user" });
void queryClient.invalidateQueries({ queryKey: ["users"] });
}
});
const rows = query.data?.map((user) => ({
username: user.username,
@@ -24,13 +41,30 @@ export function UsersPage() {
status: user.is_active ? "active" : "disabled"
})) ?? [];
function onSubmit(event: FormEvent) {
event.preventDefault();
createMutation.mutate(form);
}
return (
<Page
title="Users"
subtitle="Create, disable, and manage platform identities."
actions={<button className="button">New user</button>}
actions={<span className="pill">Admin managed</span>}
>
<form className="inline-form" onSubmit={onSubmit}>
<input placeholder="username" value={form.username} onChange={(event) => setForm((value) => ({ ...value, username: event.target.value }))} />
<input placeholder="display name" value={form.display_name} onChange={(event) => setForm((value) => ({ ...value, display_name: event.target.value }))} />
<input placeholder="email" value={form.email} onChange={(event) => setForm((value) => ({ ...value, email: event.target.value }))} />
<input placeholder="password" type="password" value={form.password} onChange={(event) => setForm((value) => ({ ...value, password: event.target.value }))} />
<select value={form.role} onChange={(event) => setForm((value) => ({ ...value, role: event.target.value }))}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<button className="button" type="submit" disabled={createMutation.isPending}>Create user</button>
</form>
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create user.</p> : null}
<Table
columns={columns}
rows={rows}