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:
37
admin-web/src/features/audit/AuditPage.tsx
Normal file
37
admin-web/src/features/audit/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
admin-web/src/features/auth/LoginPage.tsx
Normal file
96
admin-web/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
admin-web/src/features/dashboard/DashboardPage.tsx
Normal file
36
admin-web/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
admin-web/src/features/devices/DevicesPage.tsx
Normal file
39
admin-web/src/features/devices/DevicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
admin-web/src/features/gateways/GatewaysPage.tsx
Normal file
37
admin-web/src/features/gateways/GatewaysPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
admin-web/src/features/policies/PoliciesPage.tsx
Normal file
41
admin-web/src/features/policies/PoliciesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
admin-web/src/features/settings/SettingsPage.tsx
Normal file
21
admin-web/src/features/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
admin-web/src/features/users/UsersPage.tsx
Normal file
41
admin-web/src/features/users/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user