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:
75
admin-web/src/api/client.ts
Normal file
75
admin-web/src/api/client.ts
Normal 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
40
admin-web/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
admin-web/src/components/Card.tsx
Normal file
5
admin-web/src/components/Card.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export function Card({ children }: PropsWithChildren) {
|
||||
return <div className="card">{children}</div>;
|
||||
}
|
||||
45
admin-web/src/components/Layout.tsx
Normal file
45
admin-web/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
admin-web/src/components/Page.tsx
Normal file
22
admin-web/src/components/Page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
admin-web/src/components/Table.tsx
Normal file
37
admin-web/src/components/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
19
admin-web/src/main.tsx
Normal file
19
admin-web/src/main.tsx
Normal 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>
|
||||
);
|
||||
227
admin-web/src/styles/global.css
Normal file
227
admin-web/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user