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,75 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export type User = {
id: string;
username: string;
display_name: string;
role: string;
is_active: boolean;
};
export type Device = {
id: string;
name: string;
user_id?: string;
platform: string;
status: string;
assigned_ip?: string;
};
export type Policy = {
id: string;
name: string;
description: string;
priority: number;
effect: string;
full_tunnel: boolean;
is_active: boolean;
destinations?: string[];
};
export type Gateway = {
id: string;
name: string;
endpoint: string;
public_key: string;
listen_port: number;
vpn_cidr: string;
dns_servers: string[];
is_active: boolean;
};
export type AuditEvent = {
id: string;
event_type: string;
entity_type: string;
status: string;
message: string;
created_at: string;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = localStorage.getItem("nexavpn_admin_token");
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {})
}
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export const api = {
users: () => request<User[]>("/admin/users"),
devices: () => request<Device[]>("/admin/devices"),
policies: () => request<Policy[]>("/admin/policies"),
gateways: () => request<Gateway[]>("/admin/gateways"),
audit: () => request<AuditEvent[]>("/admin/audit-logs")
};

40
admin-web/src/app/App.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AuditPage } from "../features/audit/AuditPage";
import { LoginPage } from "../features/auth/LoginPage";
import { DashboardPage } from "../features/dashboard/DashboardPage";
import { DevicesPage } from "../features/devices/DevicesPage";
import { GatewaysPage } from "../features/gateways/GatewaysPage";
import { PoliciesPage } from "../features/policies/PoliciesPage";
import { SettingsPage } from "../features/settings/SettingsPage";
import { UsersPage } from "../features/users/UsersPage";
export function App() {
const [token, setToken] = useState(() => localStorage.getItem("nexavpn_admin_token") ?? "");
const authenticated = useMemo(() => token.length > 0, [token]);
function handleAuthenticated(accessToken: string) {
localStorage.setItem("nexavpn_admin_token", accessToken);
setToken(accessToken);
}
return (
<Routes>
<Route
path="/login"
element={authenticated ? <Navigate to="/" replace /> : <LoginPage onAuthenticated={handleAuthenticated} />}
/>
<Route element={authenticated ? <Layout /> : <Navigate to="/login" replace />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/policies" element={<PoliciesPage />} />
<Route path="/gateways" element={<GatewaysPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,5 @@
import { PropsWithChildren } from "react";
export function Card({ children }: PropsWithChildren) {
return <div className="card">{children}</div>;
}

View File

@@ -0,0 +1,45 @@
import { NavLink, Outlet } from "react-router-dom";
const items = [
["Dashboard", "/"],
["Users", "/users"],
["Devices", "/devices"],
["Policies", "/policies"],
["Gateways", "/gateways"],
["Audit", "/audit"],
["Settings", "/settings"]
];
export function Layout() {
return (
<div className="shell">
<aside className="sidebar">
<div>
<p className="eyebrow">NexaVPN</p>
<h1>Control Plane</h1>
</div>
<nav className="nav">
{items.map(([label, path]) => (
<NavLink
key={path}
to={path}
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
>
{label}
</NavLink>
))}
</nav>
</aside>
<main className="content">
<header className="topbar">
<div>
<p className="eyebrow">Enterprise WireGuard</p>
<h2>Self-hosted VPN management</h2>
</div>
<div className="pill">Secure by design</div>
</header>
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { PropsWithChildren, ReactNode } from "react";
type PageProps = PropsWithChildren<{
title: string;
subtitle: string;
actions?: ReactNode;
}>;
export function Page({ title, subtitle, actions, children }: PageProps) {
return (
<section className="page">
<div className="page-header">
<div>
<h3>{title}</h3>
<p>{subtitle}</p>
</div>
{actions}
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { PropsWithChildren, ReactNode } from "react";
type Column = {
key: string;
label: string;
};
type TableProps<T> = {
columns: Column[];
rows: T[];
renderCell: (row: T, column: Column) => ReactNode;
};
export function Table<T>({ columns, rows, renderCell }: PropsWithChildren<TableProps<T>>) {
return (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{columns.map((column) => (
<td key={column.key}>{renderCell(row, column)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

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>
);
}

19
admin-web/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { App } from "./app/App";
import "./styles/global.css";
const client = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={client}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,227 @@
:root {
color-scheme: light dark;
--bg: #0c1322;
--bg-soft: #111b30;
--panel: rgba(17, 27, 48, 0.78);
--panel-strong: #14203a;
--text: #eff4ff;
--muted: #9fb2d4;
--line: rgba(177, 197, 229, 0.15);
--accent: #74e0b8;
--accent-strong: #1fb67a;
--shadow: 0 18px 48px rgba(3, 8, 20, 0.35);
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(116, 224, 184, 0.17), transparent 28%),
radial-gradient(circle at bottom right, rgba(98, 144, 255, 0.16), transparent 24%),
linear-gradient(180deg, #08101d 0%, #0d1728 100%);
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
.shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.auth-card {
width: min(460px, 100%);
display: grid;
gap: 16px;
padding: 28px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.auth-card label {
display: grid;
gap: 8px;
color: var(--muted);
}
.auth-card input {
border: 1px solid var(--line);
background: rgba(8, 14, 26, 0.86);
color: var(--text);
border-radius: 14px;
padding: 14px 16px;
}
.auth-copy {
margin: 0;
color: var(--muted);
}
.sidebar {
padding: 28px;
border-right: 1px solid var(--line);
background: rgba(7, 12, 22, 0.76);
backdrop-filter: blur(18px);
}
.nav {
display: grid;
gap: 10px;
margin-top: 40px;
}
.nav-link {
padding: 12px 14px;
border-radius: 14px;
color: var(--muted);
transition: 180ms ease;
}
.nav-link:hover,
.nav-link.active {
color: var(--text);
background: rgba(116, 224, 184, 0.12);
}
.content {
padding: 28px;
}
.topbar,
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.page {
display: grid;
gap: 22px;
}
.grid {
display: grid;
gap: 18px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card,
.table-wrap {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
padding: 22px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: 14px 10px;
border-bottom: 1px solid var(--line);
}
.table tbody tr:last-child td {
border-bottom: 0;
}
.button,
.pill {
border: 0;
padding: 11px 16px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #04141a;
font-weight: 700;
}
.ghost-button {
border: 1px solid var(--line);
padding: 11px 16px;
border-radius: 999px;
background: transparent;
color: var(--text);
}
.pill {
display: inline-flex;
align-items: center;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.75rem;
}
.metric-label,
.page-header p,
.card p {
color: var(--muted);
}
.metric-value {
display: block;
margin: 10px 0;
font-size: 2rem;
}
.notice {
margin: 0;
color: #ffcf9b;
}
@media (max-width: 960px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.grid.two,
.grid.three {
grid-template-columns: 1fr;
}
}