diff --git a/admin-web/package.json b/admin-web/package.json
index c4ded90..a305cec 100644
--- a/admin-web/package.json
+++ b/admin-web/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.66.8",
+ "lucide-react": "^0.511.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.0"
diff --git a/admin-web/src/components/Layout.tsx b/admin-web/src/components/Layout.tsx
index 4e81ebd..0c2f15d 100644
--- a/admin-web/src/components/Layout.tsx
+++ b/admin-web/src/components/Layout.tsx
@@ -1,27 +1,43 @@
+import { ChevronRight } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom";
+import { appIconMap, topbarIcon, type AppIconKey } from "./app-icons";
+
const items = [
- ["Dashboard", "/"],
- ["Users", "/users"],
- ["Groups", "/groups"],
- ["Devices", "/devices"],
- ["Services", "/services"],
- ["Policies", "/policies"],
- ["Gateways", "/gateways"],
- ["Audit", "/audit"],
- ["Settings", "/settings"]
-];
+ ["Dashboard", "/", "dashboard"],
+ ["Users", "/users", "users"],
+ ["Groups", "/groups", "groups"],
+ ["Devices", "/devices", "devices"],
+ ["Services", "/services", "services"],
+ ["Policies", "/policies", "policies"],
+ ["Gateways", "/gateways", "gateways"],
+ ["Audit", "/audit", "audit"],
+ ["Settings", "/settings", "settings"]
+] as const satisfies ReadonlyArray<[string, string, AppIconKey]>;
type LayoutProps = {
onLogout: () => void;
};
+const TopbarIcon = topbarIcon;
+
+function BrandGlyph() {
+ return (
+
+ );
+}
+
export function Layout({ onLogout }: LayoutProps) {
return (
-

-
+
+
+
+
Enterprise WireGuard
Self-hosted VPN management
diff --git a/admin-web/src/components/Page.tsx b/admin-web/src/components/Page.tsx
index a44d1fc..3a595ef 100644
--- a/admin-web/src/components/Page.tsx
+++ b/admin-web/src/components/Page.tsx
@@ -1,5 +1,8 @@
+import { useLocation } from "react-router-dom";
import { PropsWithChildren, ReactNode } from "react";
+import { appIconMap, pageMetaByPath } from "./app-icons";
+
type PageProps = PropsWithChildren<{
title: string;
subtitle: string;
@@ -7,11 +10,23 @@ type PageProps = PropsWithChildren<{
}>;
export function Page({ title, subtitle, actions, children }: PageProps) {
+ const location = useLocation();
+ const meta = pageMetaByPath[location.pathname] ?? pageMetaByPath["/"];
+ const Icon = appIconMap[meta.icon];
+
return (
-
-
{title}
+
+
+
+
+
+
{meta.eyebrow}
+
{title}
+
+
+
{actions}
diff --git a/admin-web/src/components/app-icons.tsx b/admin-web/src/components/app-icons.tsx
new file mode 100644
index 0000000..6502dcf
--- /dev/null
+++ b/admin-web/src/components/app-icons.tsx
@@ -0,0 +1,50 @@
+import {
+ Activity,
+ FileText,
+ FolderLock,
+ Gauge,
+ HardDrive,
+ LockKeyhole,
+ type LucideIcon,
+ Network,
+ Settings,
+ Shield,
+ Users
+} from "lucide-react";
+
+export type AppIconKey =
+ | "dashboard"
+ | "users"
+ | "groups"
+ | "devices"
+ | "services"
+ | "policies"
+ | "gateways"
+ | "audit"
+ | "settings";
+
+export const appIconMap: Record
= {
+ dashboard: Gauge,
+ users: Users,
+ groups: FolderLock,
+ devices: HardDrive,
+ services: Shield,
+ policies: LockKeyhole,
+ gateways: Network,
+ audit: FileText,
+ settings: Settings
+};
+
+export const pageMetaByPath: Record = {
+ "/": { icon: "dashboard", eyebrow: "Operations" },
+ "/users": { icon: "users", eyebrow: "Identity" },
+ "/groups": { icon: "groups", eyebrow: "Access Groups" },
+ "/devices": { icon: "devices", eyebrow: "Endpoints" },
+ "/services": { icon: "services", eyebrow: "Published Services" },
+ "/policies": { icon: "policies", eyebrow: "Enforcement" },
+ "/gateways": { icon: "gateways", eyebrow: "WireGuard Edge" },
+ "/audit": { icon: "audit", eyebrow: "Traceability" },
+ "/settings": { icon: "settings", eyebrow: "Platform" }
+};
+
+export const topbarIcon = Activity;
diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx
index 5d4cc58..b373f0d 100644
--- a/admin-web/src/features/devices/DevicesPage.tsx
+++ b/admin-web/src/features/devices/DevicesPage.tsx
@@ -11,7 +11,8 @@ const columns = [
{ key: "ip", label: "VPN IP" },
{ key: "received", label: "Received" },
{ key: "sent", label: "Sent" },
- { key: "status", label: "Status" }
+ { key: "status", label: "Status" },
+ { key: "actions", label: "Actions" }
];
function formatDataSize(bytes: number) {
@@ -37,21 +38,13 @@ export function DevicesPage() {
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 deleteMutation = useMutation({
mutationFn: api.deleteDevice,
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: ["devices"] });
- void queryClient.invalidateQueries({ queryKey: ["device-profile"] });
- }
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
});
const rotateMutation = useMutation({
mutationFn: api.rotateDevice,
@@ -75,28 +68,26 @@ export function DevicesPage() {
{row[column.key as keyof (typeof rows)[number]]}}
+ renderCell={(row, column) => {
+ if (column.key === "actions") {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return {row[column.key as keyof (typeof rows)[number]]};
+ }}
/>
- {rows.length > 0 ? (
-
-
-
Selected device profile
-
Admin view shows a debug template. The client private key stays on the device.
-
{profileQuery.data?.profile.content ?? "Loading profile..."}
-
-
-
Device actions
-
Target: {rows[0].name}
-
Total received: {rows[0].received}
-
Total sent: {rows[0].sent}
-
-
-
-
-
-
-
- ) : null}
);
}
diff --git a/admin-web/src/features/groups/GroupsPage.tsx b/admin-web/src/features/groups/GroupsPage.tsx
index bc00027..e2474d6 100644
--- a/admin-web/src/features/groups/GroupsPage.tsx
+++ b/admin-web/src/features/groups/GroupsPage.tsx
@@ -130,8 +130,13 @@ export function GroupsPage() {
{(usersQuery.data ?? []).map((user) => (
))}
@@ -169,8 +174,13 @@ export function GroupsPage() {
{(usersQuery.data ?? []).map((user) => (
))}
diff --git a/admin-web/src/features/policies/PoliciesPage.tsx b/admin-web/src/features/policies/PoliciesPage.tsx
index 24da969..efd7850 100644
--- a/admin-web/src/features/policies/PoliciesPage.tsx
+++ b/admin-web/src/features/policies/PoliciesPage.tsx
@@ -234,8 +234,13 @@ export function PoliciesPage() {
{selectableTargets.map((target) => (
))}
@@ -251,8 +256,11 @@ export function PoliciesPage() {
{selectableServices.map((service) => (
))}
@@ -282,8 +290,13 @@ export function PoliciesPage() {
{editableTargets.map((target) => (
))}
@@ -299,8 +312,11 @@ export function PoliciesPage() {
{selectableServices.map((service) => (
))}
diff --git a/admin-web/src/styles/global.css b/admin-web/src/styles/global.css
index 772f513..92c9107 100644
--- a/admin-web/src/styles/global.css
+++ b/admin-web/src/styles/global.css
@@ -90,15 +90,47 @@ button {
align-items: flex-start;
}
+.brand-glyph,
+.topbar-icon-wrap,
+.page-title-icon {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: var(--accent);
+ border: 1px solid rgba(116, 224, 184, 0.18);
+ background:
+ radial-gradient(circle at top, rgba(116, 224, 184, 0.14), transparent 72%),
+ rgba(10, 18, 32, 0.88);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
+}
+
+.brand-glyph {
+ width: 54px;
+ height: 54px;
+ border-radius: 18px;
+}
+
+.brand-glyph svg {
+ width: 25px;
+ height: 25px;
+}
+
.brand-copy {
display: grid;
gap: 6px;
}
+.brand-copy h1,
+.topbar-copy h2,
+.page-title-copy h3 {
+ margin: 0;
+ line-height: 1.08;
+}
+
.brand-tagline {
margin: 0;
color: var(--muted);
- max-width: 240px;
+ max-width: 220px;
line-height: 1.5;
}
@@ -151,12 +183,17 @@ button {
.nav {
display: grid;
gap: 10px;
- margin-top: 40px;
+ margin-top: 36px;
}
.nav-link {
- padding: 12px 14px;
- border-radius: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 13px 14px 13px 16px;
+ border-radius: 16px;
+ border: 1px solid transparent;
color: var(--muted);
transition: 180ms ease;
}
@@ -164,26 +201,85 @@ button {
.nav-link:hover,
.nav-link.active {
color: var(--text);
- background: rgba(116, 224, 184, 0.12);
+ border-color: rgba(116, 224, 184, 0.14);
+ background: rgba(116, 224, 184, 0.08);
+}
+
+.nav-link-copy {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.nav-link-icon,
+.nav-link-chevron {
+ flex: 0 0 auto;
+}
+
+.nav-link:hover .nav-link-icon,
+.nav-link.active .nav-link-icon,
+.nav-link:hover .nav-link-chevron,
+.nav-link.active .nav-link-chevron {
+ color: var(--accent);
}
.content {
padding: 28px;
}
-.topbar,
-.page-header {
+.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
+.topbar-copy {
+ display: grid;
+ gap: 6px;
+}
+
+.topbar-icon-wrap,
+.page-title-icon {
+ width: 52px;
+ height: 52px;
+ border-radius: 18px;
+}
+
.page {
display: grid;
gap: 22px;
}
+.page-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 16px;
+}
+
+.page-title-block {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.page-title-copy {
+ display: grid;
+ gap: 6px;
+}
+
+.page-subtitle-block {
+ grid-column: 1 / 2;
+ padding-left: 68px;
+}
+
+.page-subtitle-block p {
+ margin: 0;
+ max-width: 760px;
+}
+
.grid {
display: grid;
gap: 18px;
@@ -345,15 +441,52 @@ button {
.selection-list {
display: grid;
gap: 10px;
- max-height: 240px;
+ max-height: 260px;
overflow: auto;
}
.selection-item {
- display: flex;
+ display: grid;
+ grid-template-columns: 1fr auto;
align-items: center;
- gap: 10px;
+ gap: 12px;
+ padding: 14px 16px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(8, 14, 26, 0.78);
color: var(--text);
+ cursor: pointer;
+ transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
+}
+
+.selection-item:hover {
+ border-color: rgba(116, 224, 184, 0.32);
+ background: rgba(14, 22, 38, 0.96);
+}
+
+.selection-item input {
+ margin: 0;
+}
+
+.selection-copy {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.selection-title {
+ font-weight: 600;
+ color: var(--text);
+}
+
+.selection-meta {
+ color: var(--muted);
+ font-size: 0.92rem;
+ line-height: 1.35;
+}
+
+.selection-item input:checked + .selection-copy .selection-title {
+ color: var(--accent);
}
.modal-backdrop {