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 (
- NexaVPN mark -
+ +

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}

+
+
+

{subtitle}

{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 {