Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen - ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests pruefen das. Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der Vorgabe Techniker. Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
8.2 KiB
TypeScript
237 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
|
import type { Rolle } from "@/generated/prisma/enums";
|
|
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
|
|
|
export interface KopfleistenAlert {
|
|
id: string;
|
|
titel: string;
|
|
beschreibung: string;
|
|
schwere: "INFO" | "WARNUNG" | "GEFAHR";
|
|
hostId: string | null;
|
|
}
|
|
|
|
const SCHWERE_ICON: Record<KopfleistenAlert["schwere"], string> = {
|
|
GEFAHR: "bi-exclamation-octagon-fill pl-text-danger",
|
|
WARNUNG: "bi-exclamation-triangle-fill pl-text-warning",
|
|
INFO: "bi-info-circle-fill text-body-secondary",
|
|
};
|
|
|
|
/** Farbschema aus dem <html>-Attribut lesen, damit Server und Client übereinstimmen. */
|
|
function themaIstDunkel() {
|
|
return document.documentElement.dataset.bsTheme === "dark";
|
|
}
|
|
|
|
function themaAbonnieren(aenderung: () => void) {
|
|
const beobachter = new MutationObserver(aenderung);
|
|
beobachter.observe(document.documentElement, { attributes: true, attributeFilter: ["data-bs-theme"] });
|
|
return () => beobachter.disconnect();
|
|
}
|
|
|
|
export function Header({
|
|
alerts,
|
|
suchEintraege,
|
|
benutzer,
|
|
rolle,
|
|
}: {
|
|
alerts: KopfleistenAlert[];
|
|
suchEintraege: SuchEintrag[];
|
|
benutzer: string;
|
|
rolle: Rolle;
|
|
}) {
|
|
const [sucheOffen, setSucheOffen] = useState(false);
|
|
const [glockeOffen, setGlockeOffen] = useState(false);
|
|
const [benutzerOffen, setBenutzerOffen] = useState(false);
|
|
const dunkel = useSyncExternalStore(themaAbonnieren, themaIstDunkel, () => false);
|
|
|
|
useEffect(() => {
|
|
function tasten(event: KeyboardEvent) {
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
|
event.preventDefault();
|
|
setSucheOffen(true);
|
|
}
|
|
}
|
|
window.addEventListener("keydown", tasten);
|
|
return () => window.removeEventListener("keydown", tasten);
|
|
}, []);
|
|
|
|
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
|
|
const sidebarUmschalten = useCallback(() => {
|
|
const body = document.body;
|
|
if (window.innerWidth < 992) {
|
|
body.classList.toggle("sidebar-open");
|
|
body.classList.remove("sidebar-collapse");
|
|
} else {
|
|
body.classList.toggle("sidebar-collapse");
|
|
body.classList.remove("sidebar-open");
|
|
}
|
|
}, []);
|
|
|
|
function themaUmschalten() {
|
|
const neu = dunkel ? "light" : "dark";
|
|
document.documentElement.dataset.bsTheme = neu;
|
|
try {
|
|
localStorage.setItem("pl-theme", neu);
|
|
} catch {
|
|
// Speicherzugriff kann blockiert sein; das Thema gilt dann nur für diese Sitzung.
|
|
}
|
|
}
|
|
|
|
function vollbild() {
|
|
if (document.fullscreenElement) {
|
|
void document.exitFullscreen();
|
|
} else {
|
|
void document.documentElement.requestFullscreen();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<nav className="app-header navbar navbar-expand bg-body shadow-sm">
|
|
<div className="container-fluid">
|
|
<ul className="navbar-nav">
|
|
<li className="nav-item">
|
|
<button
|
|
type="button"
|
|
className="nav-link btn btn-link pl-touch-target"
|
|
onClick={sidebarUmschalten}
|
|
aria-label="Menü ein- oder ausklappen"
|
|
>
|
|
<i className="bi bi-list fs-5" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
<li className="nav-item d-none d-md-block">
|
|
<Link href="/" className="nav-link">
|
|
Start
|
|
</Link>
|
|
</li>
|
|
<li className="nav-item d-none d-md-block">
|
|
<Link href="/einstellungen/tokens" className="nav-link">
|
|
Agent installieren
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
|
|
<ul className="navbar-nav ms-auto align-items-center">
|
|
<li className="nav-item">
|
|
<button
|
|
type="button"
|
|
className="nav-link btn btn-link pl-touch-target"
|
|
onClick={() => setSucheOffen(true)}
|
|
aria-label="Globale Suche öffnen (Strg+K)"
|
|
title="Suche (Strg+K)"
|
|
>
|
|
<i className="bi bi-search" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
|
|
<li className="nav-item dropdown">
|
|
<button
|
|
type="button"
|
|
className="nav-link btn btn-link position-relative pl-touch-target"
|
|
onClick={() => {
|
|
setGlockeOffen((o) => !o);
|
|
setBenutzerOffen(false);
|
|
}}
|
|
aria-label={`${alerts.length} offene Warnungen anzeigen`}
|
|
aria-expanded={glockeOffen}
|
|
>
|
|
<i className="bi bi-bell-fill" aria-hidden="true" />
|
|
{alerts.length > 0 ? (
|
|
<span className="badge pl-bg-danger position-absolute top-0 start-100 translate-middle rounded-pill">
|
|
{alerts.length}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
<div
|
|
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
|
|
style={{ width: "22rem" }}
|
|
>
|
|
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
|
<div className="dropdown-divider" />
|
|
{alerts.slice(0, 6).map((alert) => (
|
|
<Link
|
|
key={alert.id}
|
|
href={alert.hostId ? `/hosts/${alert.hostId}` : "/warnungen"}
|
|
className="dropdown-item d-flex gap-2 py-2"
|
|
onClick={() => setGlockeOffen(false)}
|
|
>
|
|
<i className={`bi ${SCHWERE_ICON[alert.schwere]} mt-1`} aria-hidden="true" />
|
|
<span className="text-wrap">
|
|
<span className="d-block fw-semibold">{alert.titel}</span>
|
|
<span className="d-block small text-body-secondary">{alert.beschreibung}</span>
|
|
</span>
|
|
</Link>
|
|
))}
|
|
<div className="dropdown-divider" />
|
|
<Link
|
|
href="/warnungen"
|
|
className="dropdown-item text-center"
|
|
onClick={() => setGlockeOffen(false)}
|
|
>
|
|
Alle Warnungen ansehen
|
|
</Link>
|
|
</div>
|
|
</li>
|
|
|
|
<li className="nav-item">
|
|
<button
|
|
type="button"
|
|
className="nav-link btn btn-link pl-touch-target"
|
|
onClick={themaUmschalten}
|
|
aria-label={dunkel ? "Helles Design verwenden" : "Dunkles Design verwenden"}
|
|
title={dunkel ? "Helles Design" : "Dunkles Design"}
|
|
>
|
|
<i className={`bi ${dunkel ? "bi-sun" : "bi-moon-stars"}`} aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
|
|
<li className="nav-item d-none d-md-block">
|
|
<button
|
|
type="button"
|
|
className="nav-link btn btn-link pl-touch-target"
|
|
onClick={vollbild}
|
|
aria-label="Vollbild umschalten"
|
|
>
|
|
<i className="bi bi-arrows-fullscreen" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
|
|
<li className="nav-item dropdown">
|
|
<button
|
|
type="button"
|
|
className="nav-link btn btn-link d-flex align-items-center gap-2 pl-touch-target"
|
|
onClick={() => {
|
|
setBenutzerOffen((o) => !o);
|
|
setGlockeOffen(false);
|
|
}}
|
|
aria-expanded={benutzerOffen}
|
|
>
|
|
<i className="bi bi-person-circle" aria-hidden="true" />
|
|
<span className="d-none d-sm-inline">{benutzer}</span>
|
|
</button>
|
|
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
|
|
<span className="dropdown-header">
|
|
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
|
</span>
|
|
<div className="dropdown-divider" />
|
|
<Link href="/einstellungen/passkeys" className="dropdown-item">
|
|
Meine Passkeys
|
|
</Link>
|
|
<a className="dropdown-item" href="/api/auth/abmelden">
|
|
Abmelden
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{sucheOffen ? (
|
|
<SearchDialog eintraege={suchEintraege} onSchliessen={() => setSucheOffen(false)} />
|
|
) : null}
|
|
</nav>
|
|
);
|
|
}
|