chore: initial project scaffold with admin web, backend, desktop client, and deployment setup

Add monorepo structure for NexaVPN WireGuard control plane including:
- .gitignore for node_modules, build artifacts, and environment files
- README with project overview, monorepo layout, and quick start guide
- Admin web UI with React, Vite, TypeScript, and nginx reverse proxy
- API client with type definitions for users, devices, policies, gateways, and audit logs
- Admin pages for dashboard, users, devices, policies, g
This commit is contained in:
2026-03-15 16:32:34 +01:00
commit 830491cb0d
91 changed files with 5279 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "event", label: "Event" },
{ key: "entity", label: "Entity" },
{ key: "status", label: "Status" },
{ key: "time", label: "Time" }
];
export function AuditPage() {
const query = useQuery({
queryKey: ["audit"],
queryFn: api.audit
});
const rows = query.data?.map((event) => ({
event: event.event_type,
entity: event.entity_type,
status: event.status,
time: event.created_at
})) ?? [];
return (
<Page title="Audit Logs" subtitle="Review authentication, provisioning, and admin activity.">
{query.isError ? <p className="notice">Unable to load audit events from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -0,0 +1,96 @@
import { FormEvent, useState } from "react";
type LoginPageProps = {
onAuthenticated: (accessToken: string) => void;
};
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export function LoginPage({ onAuthenticated }: LoginPageProps) {
const [mode, setMode] = useState<"login" | "bootstrap">("login");
const [username, setUsername] = useState("admin");
const [displayName, setDisplayName] = useState("NexaVPN Admin");
const [password, setPassword] = useState("admin123!");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
setError(null);
try {
if (mode === "bootstrap") {
const bootstrap = await fetch(`${API_BASE}/auth/bootstrap`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
display_name: displayName,
password
})
});
if (!bootstrap.ok && bootstrap.status !== 409) {
throw new Error(`Bootstrap failed: ${bootstrap.status}`);
}
}
const login = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (!login.ok) {
throw new Error("Login failed");
}
const payload = (await login.json()) as { access_token: string };
onAuthenticated(payload.access_token);
} catch (err) {
setError(err instanceof Error ? err.message : "Authentication failed");
} finally {
setLoading(false);
}
}
return (
<div className="auth-shell">
<form className="auth-card" onSubmit={onSubmit}>
<p className="eyebrow">NexaVPN Admin</p>
<h2>{mode === "login" ? "Sign in" : "Create initial admin"}</h2>
<p className="auth-copy">
{mode === "login"
? "Use your NexaVPN admin credentials."
: "Bootstrap the first admin account for a fresh deployment."}
</p>
<label>
Username
<input value={username} onChange={(event) => setUsername(event.target.value)} />
</label>
{mode === "bootstrap" ? (
<label>
Display name
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</label>
) : null}
<label>
Password
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error ? <p className="notice">{error}</p> : null}
<button className="button" disabled={loading} type="submit">
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
</button>
<button
className="ghost-button"
type="button"
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
>
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Card } from "../../components/Card";
import { Page } from "../../components/Page";
export function DashboardPage() {
return (
<Page title="Dashboard" subtitle="Operational visibility across users, devices, gateways, and policy activity.">
<div className="grid three">
<Card>
<p className="metric-label">Active users</p>
<strong className="metric-value">248</strong>
<span>12 enrolled this week</span>
</Card>
<Card>
<p className="metric-label">Connected devices</p>
<strong className="metric-value">181</strong>
<span>7 pending rotation</span>
</Card>
<Card>
<p className="metric-label">Gateway health</p>
<strong className="metric-value">Healthy</strong>
<span>Last sync 36s ago</span>
</Card>
</div>
<div className="grid two">
<Card>
<h4>Recent enrollments</h4>
<p>New devices are issued profiles automatically after successful sign-in.</p>
</Card>
<Card>
<h4>Policy posture</h4>
<p>Gateway enforcement is generated from effective allow-lists backed by nftables.</p>
</Card>
</div>
</Page>
);
}

View File

@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "name", label: "Device" },
{ key: "owner", label: "Owner" },
{ key: "platform", label: "Platform" },
{ key: "ip", label: "VPN IP" },
{ key: "status", label: "Status" }
];
export function DevicesPage() {
const query = useQuery({
queryKey: ["devices"],
queryFn: api.devices
});
const rows = query.data?.map((device) => ({
name: device.name,
owner: device.user_id ?? "assigned user",
platform: device.platform,
ip: device.assigned_ip ?? "-",
status: device.status
})) ?? [];
return (
<Page title="Devices" subtitle="Inspect enrolled clients, assignments, and profile lifecycle.">
{query.isError ? <p className="notice">Unable to load devices from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "name", label: "Gateway" },
{ key: "endpoint", label: "Endpoint" },
{ key: "cidr", label: "VPN CIDR" },
{ key: "status", label: "Status" }
];
export function GatewaysPage() {
const query = useQuery({
queryKey: ["gateways"],
queryFn: api.gateways
});
const rows = query.data?.map((gateway) => ({
name: gateway.name,
endpoint: gateway.endpoint,
cidr: gateway.vpn_cidr,
status: gateway.is_active ? "active" : "inactive"
})) ?? [];
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}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "name", label: "Policy" },
{ key: "targets", label: "Targets" },
{ key: "destinations", label: "Destinations" },
{ key: "mode", label: "Mode" }
];
export function PoliciesPage() {
const query = useQuery({
queryKey: ["policies"],
queryFn: api.policies
});
const rows = query.data?.map((policy) => ({
name: policy.name,
targets: "assigned targets",
destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"),
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
})) ?? [];
return (
<Page
title="Policies"
subtitle="Manage per-user and per-device access controls enforced at the gateway."
actions={<button className="button">New policy</button>}
>
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -0,0 +1,21 @@
import { Card } from "../../components/Card";
import { Page } from "../../components/Page";
export function SettingsPage() {
return (
<Page title="Settings" subtitle="Default DNS, token lifetime, and deployment configuration surfaces.">
<div className="grid two">
<Card>
<h4>Auth</h4>
<p>Access token TTL: 15 minutes</p>
<p>Refresh token TTL: 30 days</p>
</Card>
<Card>
<h4>VPN</h4>
<p>Default DNS: 10.20.0.53</p>
<p>Address pool: 100.96.0.0/24</p>
</Card>
</div>
</Page>
);
}

View File

@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "username", label: "Username" },
{ key: "name", label: "Display name" },
{ key: "role", label: "Role" },
{ key: "status", label: "Status" }
];
export function UsersPage() {
const query = useQuery({
queryKey: ["users"],
queryFn: api.users
});
const rows = query.data?.map((user) => ({
username: user.username,
name: user.display_name,
role: user.role,
status: user.is_active ? "active" : "disabled"
})) ?? [];
return (
<Page
title="Users"
subtitle="Create, disable, and manage platform identities."
actions={<button className="button">New user</button>}
>
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}