Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht** (Knopf „Bericht“, Drucken/PDF über den Browser).
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Logo } from "@/components/Logo";
|
|
import { api } from "@/lib/api";
|
|
import clsx from "clsx";
|
|
import {
|
|
Cable,
|
|
FolderTree,
|
|
Globe,
|
|
GitBranch,
|
|
HardDrive,
|
|
Activity,
|
|
Clock,
|
|
Camera,
|
|
Sliders,
|
|
GitCompare,
|
|
Boxes,
|
|
KeyRound,
|
|
LayoutDashboard,
|
|
Link2,
|
|
Layers,
|
|
ListChecks,
|
|
LogOut,
|
|
Network,
|
|
RefreshCw,
|
|
Route,
|
|
Router,
|
|
ScrollText,
|
|
Search,
|
|
Server,
|
|
Settings,
|
|
Shield,
|
|
ShieldAlert,
|
|
ShieldCheck,
|
|
Tags,
|
|
Users,
|
|
Waypoints,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
import { OPEN_COMMAND_PALETTE_EVENT } from "@/lib/events";
|
|
import { useLanguage } from "@/components/LanguageProvider";
|
|
|
|
export type NavItem = { href: string; label: string; icon: typeof Network; keywords?: string[] };
|
|
export type NavGroup = { key: string; label: string; icon: typeof Network; items: NavItem[] };
|
|
|
|
// keywords catch searches that use a different word than the page's own
|
|
// label/heading (e.g. "darkmode" for the theme radio group under
|
|
// Einstellungen → Darstellung, which never spells out that word itself) —
|
|
// each list is grounded in what the target page actually shows, not
|
|
// invented, so a hit always lands somewhere the term is genuinely relevant.
|
|
export const navGroups: NavGroup[] = [
|
|
{
|
|
key: "overview",
|
|
label: "Übersicht",
|
|
icon: Network,
|
|
items: [
|
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, keywords: ["Startseite", "Home", "Offene Aktionen", "Sicherheitswarnungen", "Fleet-Status"] },
|
|
{ href: "/hosts", label: "Hosts", icon: Network, keywords: ["Firewalls", "OPNsense-Instanzen"] },
|
|
{ href: "/cluster", label: "Cluster", icon: Layers, keywords: ["CARP", "Hochverfügbarkeit", "Failover", "HA", "Master", "Backup"] },
|
|
],
|
|
},
|
|
{
|
|
key: "policy",
|
|
label: "Regelwerk",
|
|
icon: Shield,
|
|
items: [
|
|
{ href: "/rules", label: "Regeln", icon: Shield, keywords: ["Firewall-Regeln", "Filter"] },
|
|
{ href: "/policy-hierarchy", label: "Policy-Hierarchie", icon: GitBranch, keywords: ["Pre-Rules", "Post-Rules", "Vererbung", "Inheritance", "Hierarchische Policies"] },
|
|
{ href: "/groups", label: "Gruppen", icon: FolderTree, keywords: ["Gerätegruppen", "Device Groups"] },
|
|
{ href: "/aliases", label: "Aliase", icon: Tags, keywords: ["Netzwerk-Objekte", "Netzwerkgruppen"] },
|
|
{ href: "/nat", label: "NAT", icon: Network, keywords: ["Port Forward", "Portweiterleitung", "1:1 NAT", "Outbound NAT", "Masquerade"] },
|
|
],
|
|
},
|
|
{
|
|
key: "network",
|
|
label: "Netzwerk",
|
|
icon: Cable,
|
|
items: [
|
|
{ href: "/network/interfaces", label: "Schnittstellen", icon: Cable, keywords: ["Interfaces", "IP-Adressen", "WAN", "LAN", "Zuweisung"] },
|
|
{ href: "/network/vlans", label: "VLANs", icon: Layers, keywords: ["VLAN-Tag", "802.1q"] },
|
|
{ href: "/network/vips", label: "Virtuelle IPs", icon: Tags, keywords: ["CARP-VIP", "IP-Alias", "VHID"] },
|
|
{ href: "/network/gateways", label: "Gateways", icon: Router, keywords: ["Standard-Gateway", "Uplink"] },
|
|
{ href: "/network/routes", label: "Routen", icon: Route, keywords: ["Routing", "Statische Routen"] },
|
|
{ href: "/network/dns", label: "DNS", icon: Globe, keywords: ["Unbound", "Host-Override", "Hostnamen"] },
|
|
{ href: "/network/dhcp", label: "DHCP", icon: Server, keywords: ["Kea", "dnsmasq", "Reservierung", "Statische Zuweisung", "Subnetz", "Bereich", "Range"] },
|
|
{ href: "/network/devices", label: "Geräte", icon: Boxes, keywords: ["LAGG", "Link Aggregation", "Bridge", "Brücke", "Loopback", "GRE", "Tunnel"] },
|
|
{ href: "/network/compare", label: "Abgleich", icon: GitCompare, keywords: ["Vergleich", "Cluster", "HA", "Abweichung", "Drift", "Unterschiede"] },
|
|
],
|
|
},
|
|
{
|
|
key: "hostsystem",
|
|
label: "Host-System",
|
|
icon: HardDrive,
|
|
items: [
|
|
{ href: "/hostsystem/tunables", label: "Tunables", icon: Sliders, keywords: ["sysctl", "Kernel", "kTLS", "Systemparameter"] },
|
|
{ href: "/hostsystem/cron", label: "Cron", icon: Clock, keywords: ["Zeitplan", "Aufgaben", "Jobs", "geplante Aufgaben"] },
|
|
{ href: "/hostsystem/syslog", label: "Syslog", icon: ScrollText, keywords: ["Remote-Logging", "Log-Ziel", "Logserver"] },
|
|
{ href: "/hostsystem/snapshots", label: "Snapshots", icon: Camera, keywords: ["ZFS", "Boot-Umgebung", "Sicherung vor Änderung"] },
|
|
{ href: "/hostsystem/diagnostics", label: "Diagnose", icon: Activity, keywords: ["Ping", "Traceroute", "Port prüfen", "Portprobe", "DNS-Abfrage", "Erreichbarkeit", "Fehlersuche"] },
|
|
],
|
|
},
|
|
{
|
|
key: "vpn",
|
|
label: "VPN",
|
|
icon: KeyRound,
|
|
items: [
|
|
{ href: "/ipsec", label: "IPsec-VPN", icon: KeyRound, keywords: ["Site-to-Site", "PSK", "Pre-Shared Key", "Tunnel"] },
|
|
{ href: "/openvpn", label: "OpenVPN", icon: Cable, keywords: ["Server", "Client", "TLS-Auth", "TLS-Crypt"] },
|
|
{ href: "/wireguard", label: "WireGuard", icon: Waypoints, keywords: ["Peer", "Tunnel", "Endpoint"] },
|
|
],
|
|
},
|
|
{
|
|
key: "certificates",
|
|
label: "Zertifikate",
|
|
icon: ShieldCheck,
|
|
items: [{ href: "/certificates", label: "Zertifikate", icon: ShieldCheck, keywords: ["TLS", "SSL", "Let's Encrypt", "ACME", "Trust-Store", "CA"] }],
|
|
},
|
|
{
|
|
key: "monitoring",
|
|
label: "Monitoring",
|
|
icon: ScrollText,
|
|
items: [
|
|
{ href: "/logs", label: "Logs", icon: ScrollText, keywords: ["Firewall-Logs", "Geblockt", "Blocked", "Top Ports"] },
|
|
{ href: "/security", label: "Sicherheitslage", icon: ShieldAlert, keywords: ["Compliance", "Härtung", "Score", "Regel-Hygiene", "Any-Any", "SSH", "Sicherheit", "Prüfung"] },
|
|
{ href: "/audit", label: "Audit", icon: ScrollText, keywords: ["Deploy-Historie", "Protokoll", "Verlauf"] },
|
|
],
|
|
},
|
|
{
|
|
key: "system",
|
|
label: "System",
|
|
icon: Settings,
|
|
items: [
|
|
{
|
|
href: "/updates",
|
|
label: "Update Center",
|
|
icon: RefreshCw,
|
|
keywords: ["Firmware", "OPNsense-Updates", "Pakete", "Wartung", "Neustart erforderlich", "Versionen"],
|
|
},
|
|
{
|
|
href: "/settings",
|
|
label: "Einstellungen",
|
|
icon: Settings,
|
|
keywords: [
|
|
"Darstellung",
|
|
"Dark Mode",
|
|
"Darkmode",
|
|
"Light Mode",
|
|
"Theme",
|
|
"Farbschema",
|
|
"Hell",
|
|
"Dunkel",
|
|
"Passwort ändern",
|
|
"Password",
|
|
"Konto",
|
|
"Benachrichtigungen",
|
|
"Webhook",
|
|
"E-Mail",
|
|
"SMTP",
|
|
],
|
|
},
|
|
{
|
|
href: "/users",
|
|
label: "Benutzer",
|
|
icon: Users,
|
|
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
|
|
},
|
|
{
|
|
href: "/overlay",
|
|
label: "Management-Tunnel",
|
|
icon: Link2,
|
|
keywords: ["Overlay", "WireGuard", "NAT", "CGNAT", "Hub", "hinter NAT", "Fernzugriff", "Außenstelle", "Filiale", "ohne öffentliche IP"],
|
|
},
|
|
{
|
|
href: "/tokens",
|
|
label: "API-Tokens",
|
|
icon: KeyRound,
|
|
keywords: ["API", "Token", "Bearer", "Prometheus", "Metriken", "Automatisierung", "CI", "Skript", "MCP", "Schlüssel"],
|
|
},
|
|
{
|
|
href: "/setup",
|
|
label: "Einrichtung",
|
|
icon: ListChecks,
|
|
keywords: ["Assistent", "Wizard", "Setup", "Erste Schritte", "Onboarding", "Firewall verbinden", "API-Key", "Einführung"],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
/** i18n key for a nav item: "/network/vlans" -> nav.items.network-vlans (a slash would read as a nested path). */
|
|
export function navLabelKey(href: string): string {
|
|
return `nav.items.${href.slice(1).replace(/\//g, "-")}`;
|
|
}
|
|
|
|
/** True when pathname is the nav item's page or below it ("/hostsystem/x" must NOT match "/hosts"). */
|
|
export function matchesNav(pathname: string | null | undefined, href: string): boolean {
|
|
return !!pathname && (pathname === href || pathname.startsWith(href + "/"));
|
|
}
|
|
|
|
export function Nav() {
|
|
const { t } = useLanguage();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [readOnly, setReadOnly] = useState(false);
|
|
|
|
const activeGroup = navGroups.find((g) => g.items.some((i) => matchesNav(pathname, i.href))) ?? null;
|
|
|
|
useEffect(() => {
|
|
api.me().then((state) => setReadOnly(state.role === "read_only")).catch(() => {});
|
|
}, []);
|
|
|
|
async function handleLogout() {
|
|
await api.logout();
|
|
router.push("/login");
|
|
}
|
|
|
|
return (
|
|
<header className="border-b border-slate-200 bg-white print:hidden">
|
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3 sm:px-6">
|
|
<Link href="/about" title={t("nav.aboutTitle")} className="shrink-0">
|
|
<Logo className="h-6 w-auto sm:h-7" />
|
|
</Link>
|
|
<nav aria-label={t("nav.mainNavigation")} className="order-3 flex w-full min-w-0 gap-0.5 overflow-x-auto sm:order-none sm:w-auto">
|
|
{navGroups.map(({ key, icon: Icon, items }) => {
|
|
const active = activeGroup?.key === key;
|
|
return (
|
|
<Link
|
|
key={key}
|
|
href={items[0].href}
|
|
title={t(`nav.groups.${key}`)}
|
|
aria-label={t(`nav.groups.${key}`)}
|
|
aria-current={active ? "true" : undefined}
|
|
className={clsx(
|
|
"flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors",
|
|
active ? "bg-brand-50 text-brand-600" : "text-slate-600 hover:bg-slate-100"
|
|
)}
|
|
>
|
|
<Icon size={16} aria-hidden="true" />
|
|
<span className="hidden min-[1150px]:inline">{t(`nav.groups.${key}`)}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
{readOnly && (
|
|
<span
|
|
title={t("nav.readOnlyDescription")}
|
|
className="ml-auto shrink-0 rounded-full bg-flame-50 px-2.5 py-1 text-xs font-medium text-flame-800"
|
|
>
|
|
{t("nav.readOnly")}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
|
title={t("nav.search")}
|
|
aria-label={t("nav.search")}
|
|
className={clsx(
|
|
"flex shrink-0 items-center gap-2 rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 sm:px-3",
|
|
!readOnly && "ml-auto"
|
|
)}
|
|
>
|
|
<Search size={16} aria-hidden="true" />
|
|
<span className="hidden text-xs text-slate-400 sm:inline">⌘K</span>
|
|
</button>
|
|
<button
|
|
onClick={handleLogout}
|
|
title={t("nav.logout")}
|
|
aria-label={t("nav.logout")}
|
|
className="flex shrink-0 items-center rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
|
>
|
|
<LogOut size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{activeGroup && activeGroup.items.length > 1 && (
|
|
<div className="border-t border-slate-100 bg-slate-50">
|
|
<nav aria-label={t("nav.subNavigation")} className="mx-auto flex max-w-6xl items-center gap-0.5 overflow-x-auto px-3 py-1.5 sm:px-6 sm:gap-1">
|
|
{activeGroup.items.map(({ href, icon: Icon }) => {
|
|
const active = matchesNav(pathname, href);
|
|
return (
|
|
<Link
|
|
key={href}
|
|
href={href}
|
|
title={t(navLabelKey(href))}
|
|
aria-label={t(navLabelKey(href))}
|
|
aria-current={active ? "page" : undefined}
|
|
className={clsx(
|
|
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-colors",
|
|
active ? "bg-white font-medium text-brand-600 shadow-sm" : "text-slate-500 hover:bg-white hover:text-slate-700"
|
|
)}
|
|
>
|
|
<Icon size={14} aria-hidden="true" />
|
|
<span className="hidden sm:inline">{t(navLabelKey(href))}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
)}
|
|
</header>
|
|
);
|
|
}
|