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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
|
||||
import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
||||
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
|
||||
Mit {oidc.bezeichnung} anmelden
|
||||
</Link>
|
||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||
<div className="text-center text-body-secondary small mb-3">oder</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<PasskeyAnmeldung ziel={ziel} />
|
||||
|
||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||
|
||||
<AnmeldeFormular ziel={ziel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BenutzerVerwaltung } from "@/components/einstellungen/BenutzerVerwaltung";
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { Callout } from "@/components/ui/Callout";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata = { title: "Benutzer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BenutzerSeite() {
|
||||
const ich = await sitzungErforderlich("verwaltung");
|
||||
|
||||
const [benutzer, kunden] = await Promise.all([
|
||||
prisma.benutzer.findMany({
|
||||
orderBy: [{ rolle: "asc" }, { name: "asc" }],
|
||||
include: { kunde: { select: { name: true } }, _count: { select: { passkeys: true } } },
|
||||
}),
|
||||
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
const zeilen = benutzer.map((eintrag) => ({
|
||||
id: eintrag.id,
|
||||
email: eintrag.email,
|
||||
name: eintrag.name,
|
||||
rolle: eintrag.rolle,
|
||||
kundeId: eintrag.kundeId,
|
||||
kundeName: eintrag.kunde?.name ?? null,
|
||||
aktiv: eintrag.aktiv,
|
||||
hatPasswort: eintrag.passwortHash !== null,
|
||||
hatOidc: eintrag.oidcSubject !== null,
|
||||
passkeys: eintrag._count.passkeys,
|
||||
letzterLoginAm: eintrag.letzterLoginAm,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel="Benutzer"
|
||||
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benutzer" }]}
|
||||
/>
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<Callout farbe="info" titel="Rollen">
|
||||
<strong>Administrator</strong> darf alles, auch Benutzer und Systemeinstellungen.{" "}
|
||||
<strong>Techniker</strong> darf alles Fachliche, aber keine Benutzer anlegen.{" "}
|
||||
<strong>Kunde</strong> sieht nur die Hosts und Rechnungen des eigenen Kunden – ohne
|
||||
Einkaufspreise – und darf Notizen schreiben sowie Warnungen quittieren.
|
||||
</Callout>
|
||||
|
||||
<BenutzerVerwaltung benutzer={zeilen} kunden={kunden} ichId={ich.benutzerId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { headers } from "next/headers";
|
||||
import { PasskeyVerwaltung } from "@/components/auth/PasskeyVerwaltung";
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { oeffentlicheBasis } from "@/lib/adresse";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { passkeysMoeglich } from "@/server/auth/passkeys";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
export const metadata = { title: "Meine Passkeys" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PasskeySeite() {
|
||||
const ich = await sitzungErforderlich();
|
||||
const { dashboardUrl } = await einstellungen();
|
||||
|
||||
const [passkeys, kopfzeilen] = await Promise.all([
|
||||
prisma.passkey.findMany({
|
||||
where: { benutzerId: ich.benutzerId },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: { id: true, bezeichnung: true, erstelltAm: true, zuletztAm: true },
|
||||
}),
|
||||
headers(),
|
||||
]);
|
||||
|
||||
// Die Seite kennt die Anfrage nur über die Kopfzeilen; daraus entsteht dieselbe Adresse,
|
||||
// die auch die Passkey-Endpunkte verwenden.
|
||||
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), dashboardUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel="Meine Passkeys"
|
||||
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Passkeys" }]}
|
||||
/>
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<PasskeyVerwaltung
|
||||
passkeys={passkeys}
|
||||
moeglich={passkeysMoeglich(basis.origin)}
|
||||
adresse={basis.origin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
|
||||
/**
|
||||
@@ -13,9 +11,9 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
|
||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
if (!sitzung) redirect("/login");
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
|
||||
// entsteht – und weil hier Rolle und Kundenbindung herkommen.
|
||||
const sitzung = await sitzungErforderlich();
|
||||
|
||||
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
||||
|
||||
@@ -31,12 +29,14 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
}))}
|
||||
suchEintraege={suche}
|
||||
benutzer={sitzung.name}
|
||||
rolle={sitzung.rolle}
|
||||
/>
|
||||
<Sidebar
|
||||
hostsAnzahl={zahlen.hostsGesamt}
|
||||
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
||||
offeneWarnungen={zahlen.offeneAlerts}
|
||||
benutzer={sitzung.name}
|
||||
rolle={sitzung.rolle}
|
||||
/>
|
||||
<main className="app-main">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen, ueberHttps } from "@/lib/sitzung";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { anmeldungPruefen, anmeldungVorbereiten, herkunft } from "@/server/auth/passkeys";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CHALLENGE_COOKIE = "pl_passkey_anmeldung";
|
||||
|
||||
function challengeOptionen() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 300,
|
||||
};
|
||||
}
|
||||
|
||||
/** Schritt 1: Aufgabe für die Anmeldung erzeugen – ohne Benutzernamen, der Browser wählt den Schlüssel. */
|
||||
export async function GET(request: Request) {
|
||||
const optionen = await anmeldungVorbereiten(await herkunft(request));
|
||||
(await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
|
||||
return Response.json(optionen);
|
||||
}
|
||||
|
||||
/** Schritt 2: Antwort prüfen und die Sitzung anlegen. */
|
||||
export async function POST(request: Request) {
|
||||
const speicher = await cookies();
|
||||
const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!challenge) {
|
||||
return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const antwort = await request.json();
|
||||
const benutzer = await anmeldungPruefen(antwort, challenge, await herkunft(request));
|
||||
|
||||
const { sitzungsdauerStunden } = await einstellungen();
|
||||
const token = await sitzungErzeugen(
|
||||
{
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passkey",
|
||||
},
|
||||
sitzungsdauerStunden,
|
||||
);
|
||||
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
|
||||
|
||||
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
|
||||
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passkey" });
|
||||
|
||||
return Response.json({ erfolg: true });
|
||||
} catch (fehler) {
|
||||
console.error("Passkey-Anmeldung fehlgeschlagen:", fehler);
|
||||
// Bewusst unscharf: Ob es den Passkey gibt, geht einen Unbekannten nichts an.
|
||||
return Response.json({ fehler: "Anmeldung mit diesem Passkey nicht möglich." }, { status: 400 });
|
||||
} finally {
|
||||
speicher.delete(CHALLENGE_COOKIE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ueberHttps } from "@/lib/sitzung";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
import { herkunft, registrierungAbschliessen, registrierungVorbereiten } from "@/server/auth/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Die Aufgabe („challenge“) darf nur für diesen Browser und nur kurz gelten. */
|
||||
const CHALLENGE_COOKIE = "pl_passkey_registrierung";
|
||||
|
||||
function challengeOptionen() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 300,
|
||||
};
|
||||
}
|
||||
|
||||
/** Schritt 1: Aufgabe erzeugen. */
|
||||
export async function GET(request: Request) {
|
||||
const benutzer = await angemeldet();
|
||||
if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
|
||||
|
||||
const optionen = await registrierungVorbereiten(benutzer.benutzerId, await herkunft(request));
|
||||
(await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
|
||||
|
||||
return Response.json(optionen);
|
||||
}
|
||||
|
||||
/** Schritt 2: Antwort des Browsers prüfen und den Passkey hinterlegen. */
|
||||
export async function POST(request: Request) {
|
||||
const benutzer = await angemeldet();
|
||||
if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
|
||||
|
||||
const speicher = await cookies();
|
||||
const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!challenge) return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
|
||||
|
||||
try {
|
||||
const { antwort, bezeichnung } = await request.json();
|
||||
const passkey = await registrierungAbschliessen(
|
||||
benutzer.benutzerId,
|
||||
antwort,
|
||||
challenge,
|
||||
await herkunft(request),
|
||||
typeof bezeichnung === "string" ? bezeichnung : "Passkey",
|
||||
);
|
||||
|
||||
await protokolliere("passkey.angelegt", "Passkey", passkey.id, { bezeichnung: passkey.bezeichnung });
|
||||
return Response.json({ erfolg: true, bezeichnung: passkey.bezeichnung });
|
||||
} catch (fehler) {
|
||||
console.error("Passkey-Registrierung fehlgeschlagen:", fehler);
|
||||
return Response.json(
|
||||
{ fehler: fehler instanceof Error ? fehler.message : "Der Passkey ließ sich nicht hinterlegen." },
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
speicher.delete(CHALLENGE_COOKIE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Anmeldung per Passkey. Der Knopf erscheint immer; ob ein Passkey vorliegt, weiß erst der Browser –
|
||||
* und das darf die Seite auch nicht vorher verraten (sonst ließen sich Konten ausspähen).
|
||||
*/
|
||||
export function PasskeyAnmeldung({ ziel }: { ziel: string }) {
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function anmelden() {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
|
||||
try {
|
||||
const optionen = await fetch("/api/auth/passkey/anmelden").then((antwort) => antwort.json());
|
||||
const antwort = await startAuthentication({ optionsJSON: optionen });
|
||||
|
||||
const ergebnis = await fetch("/api/auth/passkey/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(antwort),
|
||||
}).then((a) => a.json());
|
||||
|
||||
if (ergebnis.erfolg) {
|
||||
router.push(ziel.startsWith("/") ? ziel : "/");
|
||||
router.refresh();
|
||||
} else {
|
||||
setFehler(ergebnis.fehler ?? "Anmeldung nicht möglich.");
|
||||
}
|
||||
} catch (fehler) {
|
||||
setFehler(
|
||||
fehler instanceof Error && fehler.name === "NotAllowedError"
|
||||
? "Abgebrochen."
|
||||
: "Mit diesem Gerät geht es nicht. Passkeys brauchen HTTPS.",
|
||||
);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary w-100 pl-touch-target mb-3"
|
||||
onClick={anmelden}
|
||||
disabled={laeuft}
|
||||
>
|
||||
<i className="bi bi-fingerprint me-1" aria-hidden="true" />
|
||||
{laeuft ? "Warte auf den Browser …" : "Mit Passkey anmelden"}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { formatDatumZeit } from "@/lib/format";
|
||||
import { passkeyLoeschen } from "@/server/actions/passkeys";
|
||||
|
||||
export interface PasskeyZeile {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
erstelltAm: Date;
|
||||
zuletztAm: Date | null;
|
||||
}
|
||||
|
||||
export function PasskeyVerwaltung({
|
||||
passkeys,
|
||||
moeglich,
|
||||
adresse,
|
||||
}: {
|
||||
passkeys: PasskeyZeile[];
|
||||
moeglich: boolean;
|
||||
adresse: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [bezeichnung, setBezeichnung] = useState("");
|
||||
const [meldung, setMeldung] = useState<{ art: "fehler" | "erfolg"; text: string } | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
async function registrieren() {
|
||||
setLaeuft(true);
|
||||
setMeldung(null);
|
||||
|
||||
try {
|
||||
const optionen = await fetch("/api/auth/passkey/registrieren").then((antwort) => antwort.json());
|
||||
// Hier öffnet der Browser seine eigene Abfrage (Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel).
|
||||
const antwort = await startRegistration({ optionsJSON: optionen });
|
||||
|
||||
const ergebnis = await fetch("/api/auth/passkey/registrieren", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ antwort, bezeichnung }),
|
||||
}).then((a) => a.json());
|
||||
|
||||
if (ergebnis.fehler) {
|
||||
setMeldung({ art: "fehler", text: ergebnis.fehler });
|
||||
} else {
|
||||
setMeldung({ art: "erfolg", text: `„${ergebnis.bezeichnung}“ ist hinterlegt.` });
|
||||
setBezeichnung("");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (fehler) {
|
||||
// Abbruch durch den Benutzer ist kein Fehler, sieht aber genauso aus – deshalb neutral formulieren.
|
||||
setMeldung({
|
||||
art: "fehler",
|
||||
text:
|
||||
fehler instanceof Error && fehler.name === "NotAllowedError"
|
||||
? "Abgebrochen oder abgelehnt."
|
||||
: "Der Passkey konnte nicht angelegt werden.",
|
||||
});
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card titel="Neuen Passkey anlegen" rahmen="primary">
|
||||
{!moeglich ? (
|
||||
<div className="alert alert-warning mb-3">
|
||||
<strong>Über diese Adresse geht es nicht.</strong> Passkeys verlangen HTTPS; nur{" "}
|
||||
<code>localhost</code> ist ausgenommen. Aufgerufen wurde <code>{adresse}</code>. Richten Sie
|
||||
einen Reverse Proxy mit Zertifikat ein und tragen Sie die Adresse unter{" "}
|
||||
<em>Einstellungen → System</em> ein.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meldung ? (
|
||||
<div className={`alert ${meldung.art === "fehler" ? "alert-danger" : "alert-success"}`}>
|
||||
{meldung.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="row g-3 align-items-end">
|
||||
<div className="col-12 col-md-5">
|
||||
<label className="form-label" htmlFor="passkey-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="passkey-name"
|
||||
value={bezeichnung}
|
||||
onChange={(e) => setBezeichnung(e.target.value)}
|
||||
placeholder="z. B. Arbeitsrechner oder Telefon"
|
||||
disabled={!moeglich}
|
||||
/>
|
||||
<div className="form-text">Hilft später beim Wiedererkennen, wenn mehrere hinterlegt sind.</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-4">
|
||||
<button type="button" className="btn btn-primary" onClick={registrieren} disabled={!moeglich || laeuft}>
|
||||
<i className="bi bi-fingerprint me-2" aria-hidden="true" />
|
||||
{laeuft ? "Warte auf den Browser …" : "Passkey anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card titel={`Hinterlegte Passkeys (${passkeys.length})`} rahmen="keiner">
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-body-secondary mb-0">
|
||||
Noch keiner. Ohne Passkey bleibt die Anmeldung per Passwort oder OIDC – beides funktioniert
|
||||
weiterhin.
|
||||
</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Bezeichnung</th>
|
||||
<th scope="col">Angelegt</th>
|
||||
<th scope="col">Zuletzt benutzt</th>
|
||||
<th scope="col" className="text-end">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passkeys.map((passkey) => (
|
||||
<tr key={passkey.id}>
|
||||
<td className="fw-semibold">{passkey.bezeichnung}</td>
|
||||
<td className="small">{formatDatumZeit(passkey.erstelltAm)}</td>
|
||||
<td className="small">
|
||||
{passkey.zuletztAm ? formatDatumZeit(passkey.zuletztAm) : "noch nie"}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<form action={passkeyLoeschen}>
|
||||
<input type="hidden" name="id" value={passkey.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||
Entfernen
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { formatDatumZeit } from "@/lib/format";
|
||||
import { PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||
import {
|
||||
type AktionsErgebnis,
|
||||
benutzerAnlegen,
|
||||
benutzerLoeschen,
|
||||
benutzerSpeichern,
|
||||
benutzerSperreUmschalten,
|
||||
} from "@/server/actions/benutzer";
|
||||
import { ROLLEN_BESCHREIBUNG, ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||
|
||||
export interface BenutzerZeile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
rolle: Rolle;
|
||||
kundeId: string | null;
|
||||
kundeName: string | null;
|
||||
aktiv: boolean;
|
||||
hatPasswort: boolean;
|
||||
hatOidc: boolean;
|
||||
passkeys: number;
|
||||
letzterLoginAm: Date | null;
|
||||
}
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
const ROLLEN: Rolle[] = ["ADMIN", "TECHNIKER", "KUNDE"];
|
||||
|
||||
function RollenFelder({
|
||||
eintrag,
|
||||
kunden,
|
||||
fehler,
|
||||
}: {
|
||||
eintrag?: BenutzerZeile;
|
||||
kunden: { id: string; name: string }[];
|
||||
fehler: Record<string, string>;
|
||||
}) {
|
||||
const [rolle, setRolle] = useState<Rolle>(eintrag?.rolle ?? "TECHNIKER");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`rolle-${eintrag?.id ?? "neu"}`}>
|
||||
Rolle
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id={`rolle-${eintrag?.id ?? "neu"}`}
|
||||
name="rolle"
|
||||
defaultValue={rolle}
|
||||
onChange={(e) => setRolle(e.target.value as Rolle)}
|
||||
>
|
||||
{ROLLEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{ROLLEN_TEXT[wert]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text">{ROLLEN_BESCHREIBUNG[rolle]}</div>
|
||||
</div>
|
||||
|
||||
{/* Der Kunde zählt nur für ein Kundenkonto – deshalb erscheint das Feld auch nur dann. */}
|
||||
{rolle === "KUNDE" ? (
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`kunde-${eintrag?.id ?? "neu"}`}>
|
||||
Kunde
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id={`kunde-${eintrag?.id ?? "neu"}`}
|
||||
name="kundeId"
|
||||
defaultValue={eintrag?.kundeId ?? ""}
|
||||
>
|
||||
<option value="">– bitte wählen –</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fehler.kundeId ? <div className="invalid-feedback d-block">{fehler.kundeId}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NeuFormular({ kunden }: { kunden: { id: string; name: string }[] }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(benutzerAnlegen, LEER);
|
||||
const fehler = ergebnis.felder ?? {};
|
||||
|
||||
return (
|
||||
<Card titel="Benutzer anlegen" rahmen="primary">
|
||||
<form action={aktion} className="row g-3 align-items-start">
|
||||
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
|
||||
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="neu-name">
|
||||
Name
|
||||
</label>
|
||||
<input className="form-control" id="neu-name" name="name" required />
|
||||
{fehler.name ? <div className="invalid-feedback d-block">{fehler.name}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="neu-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input type="email" className="form-control" id="neu-email" name="email" required />
|
||||
{fehler.email ? <div className="invalid-feedback d-block">{fehler.email}</div> : null}
|
||||
</div>
|
||||
|
||||
<RollenFelder kunden={kunden} fehler={fehler} />
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="neu-passwort">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="neu-passwort"
|
||||
name="passwort"
|
||||
minLength={PASSWORT_MINDESTLAENGE}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="form-text">
|
||||
Leer lassen, wenn sich das Konto nur über OIDC oder einen Passkey anmelden soll.
|
||||
</div>
|
||||
{fehler.passwort ? <div className="invalid-feedback d-block">{fehler.passwort}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary" disabled={laeuft}>
|
||||
{laeuft ? "Wird angelegt …" : "Benutzer anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Zeile({
|
||||
eintrag,
|
||||
kunden,
|
||||
ichSelbst,
|
||||
}: {
|
||||
eintrag: BenutzerZeile;
|
||||
kunden: { id: string; name: string }[];
|
||||
ichSelbst: boolean;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(benutzerSpeichern, LEER);
|
||||
const [offen, setOffen] = useState(false);
|
||||
const fehler = ergebnis.felder ?? {};
|
||||
|
||||
const anmeldewege = [
|
||||
eintrag.hatPasswort ? "Passwort" : null,
|
||||
eintrag.hatOidc ? "OIDC" : null,
|
||||
eintrag.passkeys > 0 ? `${eintrag.passkeys} Passkey${eintrag.passkeys === 1 ? "" : "s"}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="button" className="btn btn-link p-0 text-start" onClick={() => setOffen(!offen)}>
|
||||
<span className="fw-semibold">{eintrag.name}</span>
|
||||
</button>
|
||||
{ichSelbst ? <span className="badge text-bg-secondary ms-2">Sie</span> : null}
|
||||
<div className="small text-body-secondary">{eintrag.email}</div>
|
||||
</td>
|
||||
<td>
|
||||
{ROLLEN_TEXT[eintrag.rolle]}
|
||||
{eintrag.kundeName ? <div className="small text-body-secondary">{eintrag.kundeName}</div> : null}
|
||||
</td>
|
||||
<td className="small">
|
||||
{anmeldewege.length > 0 ? anmeldewege.join(" · ") : <span className="text-danger">kein Zugang</span>}
|
||||
</td>
|
||||
<td className="small">
|
||||
{eintrag.letzterLoginAm ? formatDatumZeit(eintrag.letzterLoginAm) : "noch nie"}
|
||||
</td>
|
||||
<td>
|
||||
{eintrag.aktiv ? (
|
||||
<span className="badge text-bg-success">aktiv</span>
|
||||
) : (
|
||||
<span className="badge text-bg-danger">gesperrt</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={() => setOffen(!offen)}>
|
||||
{offen ? "Schließen" : "Bearbeiten"}
|
||||
</button>
|
||||
{!ichSelbst ? (
|
||||
<>
|
||||
<form action={benutzerSperreUmschalten}>
|
||||
<input type="hidden" name="id" value={eintrag.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-warning">
|
||||
{eintrag.aktiv ? "Sperren" : "Freigeben"}
|
||||
</button>
|
||||
</form>
|
||||
<form action={benutzerLoeschen}>
|
||||
<input type="hidden" name="id" value={eintrag.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{offen ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-body-tertiary">
|
||||
<form action={aktion} className="row g-3 align-items-start">
|
||||
<input type="hidden" name="id" value={eintrag.id} />
|
||||
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
|
||||
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`name-${eintrag.id}`}>
|
||||
Name
|
||||
</label>
|
||||
<input className="form-control" id={`name-${eintrag.id}`} name="name" defaultValue={eintrag.name} />
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`email-${eintrag.id}`}>
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id={`email-${eintrag.id}`}
|
||||
name="email"
|
||||
defaultValue={eintrag.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RollenFelder eintrag={eintrag} kunden={kunden} fehler={fehler} />
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`passwort-${eintrag.id}`}>
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id={`passwort-${eintrag.id}`}
|
||||
name="passwort"
|
||||
minLength={PASSWORT_MINDESTLAENGE}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="form-text">Leer lassen heißt: unverändert. Ein neues beendet alle Sitzungen.</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary" disabled={laeuft}>
|
||||
{laeuft ? "Wird gespeichert …" : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BenutzerVerwaltung({
|
||||
benutzer,
|
||||
kunden,
|
||||
ichId,
|
||||
}: {
|
||||
benutzer: BenutzerZeile[];
|
||||
kunden: { id: string; name: string }[];
|
||||
ichId: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<NeuFormular kunden={kunden} />
|
||||
|
||||
<Card titel={`Benutzer (${benutzer.length})`} rahmen="keiner">
|
||||
<div className="table-responsive">
|
||||
<table className="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Rolle</th>
|
||||
<th scope="col">Anmeldung</th>
|
||||
<th scope="col">Zuletzt angemeldet</th>
|
||||
<th scope="col">Zustand</th>
|
||||
<th scope="col" className="text-end">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{benutzer.map((eintrag) => (
|
||||
<Zeile key={eintrag.id} eintrag={eintrag} kunden={kunden} ichSelbst={eintrag.id === ichId} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -240,6 +240,23 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
name="oidcStandardRolle"
|
||||
beschriftung="Rolle für neue OIDC-Konten"
|
||||
hinweis="Gilt nur für Konten, die bei der ersten Anmeldung automatisch entstehen."
|
||||
>
|
||||
<select
|
||||
className="form-select"
|
||||
id="oidcStandardRolle"
|
||||
name="oidcStandardRolle"
|
||||
defaultValue={werte.oidcStandardRolle}
|
||||
>
|
||||
<option value="TECHNIKER">Techniker</option>
|
||||
<option value="KUNDE">Kunde</option>
|
||||
<option value="ADMIN">Administrator</option>
|
||||
</select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
name="aufbewahrungReportsTage"
|
||||
beschriftung="Reports aufbewahren (Tage)"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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;
|
||||
@@ -33,10 +35,12 @@ 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);
|
||||
@@ -209,10 +213,12 @@ export function Header({
|
||||
<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}</span>
|
||||
<span className="dropdown-header">
|
||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||
</span>
|
||||
<div className="dropdown-divider" />
|
||||
<Link href="/einstellungen/system" className="dropdown-item">
|
||||
Einstellungen
|
||||
<Link href="/einstellungen/passkeys" className="dropdown-item">
|
||||
Meine Passkeys
|
||||
</Link>
|
||||
<a className="dropdown-item" href="/api/auth/abmelden">
|
||||
Abmelden
|
||||
|
||||
@@ -4,6 +4,8 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { LogoMitSchriftzug } from "@/components/Logo";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
|
||||
interface MenuEintrag {
|
||||
href: string;
|
||||
@@ -17,11 +19,13 @@ export function Sidebar({
|
||||
offeneRechnungspunkte,
|
||||
offeneWarnungen,
|
||||
benutzer,
|
||||
rolle,
|
||||
}: {
|
||||
hostsAnzahl: number;
|
||||
offeneRechnungspunkte: number;
|
||||
offeneWarnungen: number;
|
||||
benutzer: string;
|
||||
rolle: Rolle;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -35,7 +39,7 @@ export function Sidebar({
|
||||
icon: "bi-hdd-stack",
|
||||
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" },
|
||||
},
|
||||
{ href: "/kunden", titel: "Kunden", icon: "bi-people" },
|
||||
...(rolle === "KUNDE" ? [] : [{ href: "/kunden", titel: "Kunden", icon: "bi-people" }]),
|
||||
{
|
||||
href: "/rechnungen",
|
||||
titel: "Rechnungen",
|
||||
@@ -54,12 +58,24 @@ export function Sidebar({
|
||||
},
|
||||
];
|
||||
|
||||
// Das Menü zeigt nur, was die Rolle auch darf. Der Schutz liegt trotzdem in den Seiten
|
||||
// und Aktionen selbst – ein ausgeblendeter Eintrag ist keine Zugriffskontrolle (#027).
|
||||
const einstellungen: MenuEintrag[] = [
|
||||
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
||||
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
||||
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
||||
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
||||
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
||||
...(darf(rolle, "betrieb")
|
||||
? [
|
||||
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
||||
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
||||
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
||||
]
|
||||
: []),
|
||||
...(darf(rolle, "verwaltung")
|
||||
? [
|
||||
{ href: "/einstellungen/benutzer", titel: "Benutzer", icon: "bi-person-gear" },
|
||||
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
||||
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
||||
]
|
||||
: []),
|
||||
{ href: "/einstellungen/passkeys", titel: "Meine Passkeys", icon: "bi-fingerprint" },
|
||||
];
|
||||
|
||||
function istAktiv(href: string) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Mindestlänge für Passwörter: lieber lang als kompliziert. */
|
||||
export const PASSWORT_MINDESTLAENGE = 12;
|
||||
+3
-2
@@ -19,7 +19,7 @@ export interface Sitzung {
|
||||
email: string;
|
||||
name: string;
|
||||
/** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */
|
||||
herkunft: "passwort" | "oidc";
|
||||
herkunft: "passwort" | "oidc" | "passkey";
|
||||
}
|
||||
|
||||
function schluessel(): Uint8Array {
|
||||
@@ -55,7 +55,8 @@ export async function sitzungLesen(token: string | undefined): Promise<GeleseneS
|
||||
benutzerId: payload.benutzerId,
|
||||
email: payload.email,
|
||||
name: typeof payload.name === "string" ? payload.name : payload.email,
|
||||
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
|
||||
herkunft:
|
||||
payload.herkunft === "oidc" || payload.herkunft === "passkey" ? payload.herkunft : "passwort",
|
||||
ausgestelltAm: new Date((payload.iat ?? 0) * 1000),
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { alertsAbgleichen } from "@/server/alerts/abgleich";
|
||||
import { REGELN } from "@/server/alerts/regeln";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
@@ -16,18 +16,26 @@ function neuLaden() {
|
||||
|
||||
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
|
||||
export async function alertQuittieren(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("mitarbeiten");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.alert.update({ where: { id }, data: { quittiertAm: new Date() } });
|
||||
// Ein Kundenkonto darf nur eigene Warnungen quittieren. Bedingung und Schreiben in einem
|
||||
// Schritt: So kann zwischen Prüfung und Änderung nichts dazwischenkommen.
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
const ergebnis = await prisma.alert.updateMany({
|
||||
where: { id, ...(nurKunde ? { host: { kundeId: nurKunde } } : {}) },
|
||||
data: { quittiertAm: new Date() },
|
||||
});
|
||||
if (ergebnis.count === 0) return;
|
||||
|
||||
await protokolliere("alert.quittiert", "Alert", id, {});
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
|
||||
export async function alertSchliessen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -38,7 +46,7 @@ export async function alertSchliessen(formular: FormData) {
|
||||
|
||||
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
|
||||
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const ergebnis = await alertsAbgleichen();
|
||||
neuLaden();
|
||||
return {
|
||||
@@ -51,7 +59,7 @@ export async function warnregelnSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
for (const regel of REGELN) {
|
||||
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
|
||||
const schwellwerte: Record<string, number | number[]> = {};
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function emailKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const host = formular.get("host")?.toString().trim() ?? "";
|
||||
const port = Number(formular.get("port") ?? 587);
|
||||
@@ -80,7 +80,7 @@ export async function webhookKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const url = formular.get("url")?.toString().trim() ?? "";
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function webhookKanalSpeichern(
|
||||
}
|
||||
|
||||
export async function kanalLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
|
||||
|
||||
/** Verschickt eine Testnachricht über einen Kanal. */
|
||||
export async function testSenden(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function testSenden(formular: FormData): Promise<void> {
|
||||
|
||||
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
|
||||
export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
|
||||
|
||||
await prisma.einstellung.upsert({
|
||||
@@ -160,7 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
|
||||
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
|
||||
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
|
||||
await zusammenfassungSenden(zeitraum);
|
||||
neuLaden();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use server";
|
||||
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
fehler?: string;
|
||||
felder?: Record<string, string>;
|
||||
erfolg?: string;
|
||||
}
|
||||
|
||||
const benutzerSchema = z
|
||||
.object({
|
||||
email: z.string().email("Keine gültige E-Mail-Adresse."),
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
rolle: z.enum(["ADMIN", "TECHNIKER", "KUNDE"]),
|
||||
kundeId: z.string().nullable(),
|
||||
passwort: z
|
||||
.union([z.string().min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`), z.literal("")])
|
||||
.default(""),
|
||||
})
|
||||
.refine((werte) => werte.rolle !== "KUNDE" || werte.kundeId !== null, {
|
||||
message: "Ein Kundenkonto braucht einen Kunden.",
|
||||
path: ["kundeId"],
|
||||
});
|
||||
|
||||
function felderAus(fehler: z.ZodError): Record<string, string> {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const eintrag of fehler.issues) {
|
||||
const feld = eintrag.path[0];
|
||||
if (typeof feld === "string" && !felder[feld]) felder[feld] = eintrag.message;
|
||||
}
|
||||
return felder;
|
||||
}
|
||||
|
||||
function lesen(formular: FormData) {
|
||||
const rolle = formular.get("rolle")?.toString() ?? "TECHNIKER";
|
||||
return {
|
||||
email: formular.get("email")?.toString().trim().toLowerCase() ?? "",
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
rolle,
|
||||
// Der Kunde zählt nur bei einem Kundenkonto; sonst wird er bewusst verworfen.
|
||||
kundeId: rolle === "KUNDE" ? formular.get("kundeId")?.toString() || null : null,
|
||||
passwort: formular.get("passwort")?.toString() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function benutzerAnlegen(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich("verwaltung");
|
||||
|
||||
const ergebnis = benutzerSchema.safeParse(lesen(formular));
|
||||
if (!ergebnis.success) return { fehler: "Bitte die Angaben prüfen.", felder: felderAus(ergebnis.error) };
|
||||
|
||||
const { email, name, rolle, kundeId, passwort } = ergebnis.data;
|
||||
|
||||
if (await prisma.benutzer.findUnique({ where: { email } })) {
|
||||
return { fehler: "Diese E-Mail-Adresse ist schon vergeben.", felder: { email: "Schon vergeben." } };
|
||||
}
|
||||
|
||||
// Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder einen Passkey anmelden kann.
|
||||
await prisma.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
rolle,
|
||||
kundeId,
|
||||
passwortHash: passwort ? await hash(passwort) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliere("benutzer.angelegt", "Benutzer", email, { rolle, kundeId, mitPasswort: Boolean(passwort) });
|
||||
revalidatePath("/einstellungen/benutzer");
|
||||
|
||||
return { erfolg: `${name} wurde angelegt.` };
|
||||
}
|
||||
|
||||
export async function benutzerSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const ich = await sitzungErforderlich("verwaltung");
|
||||
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return { fehler: "Benutzer nicht gefunden." };
|
||||
|
||||
const ergebnis = benutzerSchema.safeParse(lesen(formular));
|
||||
if (!ergebnis.success) return { fehler: "Bitte die Angaben prüfen.", felder: felderAus(ergebnis.error) };
|
||||
|
||||
const { email, name, rolle, kundeId, passwort } = ergebnis.data;
|
||||
|
||||
// Wer sich selbst die Administratorrolle nimmt, sperrt sich aus der Verwaltung aus.
|
||||
if (id === ich.benutzerId && rolle !== "ADMIN") {
|
||||
return { fehler: "Die eigene Administratorrolle lässt sich nicht entziehen." };
|
||||
}
|
||||
|
||||
const andere = await prisma.benutzer.findUnique({ where: { email } });
|
||||
if (andere && andere.id !== id) {
|
||||
return { fehler: "Diese E-Mail-Adresse gehört schon zu einem anderen Konto." };
|
||||
}
|
||||
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
rolle,
|
||||
kundeId,
|
||||
// Ein neues Passwort beendet bestehende Sitzungen – sonst liefe ein geklautes Token weiter.
|
||||
...(passwort ? { passwortHash: await hash(passwort), sitzungenUngueltigAb: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliere("benutzer.geaendert", "Benutzer", id, { rolle, kundeId, passwortGesetzt: Boolean(passwort) });
|
||||
revalidatePath("/einstellungen/benutzer");
|
||||
|
||||
return { erfolg: "Gespeichert." };
|
||||
}
|
||||
|
||||
export async function benutzerSperreUmschalten(formular: FormData) {
|
||||
const ich = await sitzungErforderlich("verwaltung");
|
||||
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
if (id === ich.benutzerId) return; // sich selbst auszusperren hilft niemandem
|
||||
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id }, select: { aktiv: true } });
|
||||
if (!benutzer) return;
|
||||
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
aktiv: !benutzer.aktiv,
|
||||
// Beim Sperren laufende Sitzungen sofort beenden.
|
||||
...(benutzer.aktiv ? { sitzungenUngueltigAb: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliere(benutzer.aktiv ? "benutzer.gesperrt" : "benutzer.freigegeben", "Benutzer", id, {});
|
||||
revalidatePath("/einstellungen/benutzer");
|
||||
}
|
||||
|
||||
export async function benutzerLoeschen(formular: FormData) {
|
||||
const ich = await sitzungErforderlich("verwaltung");
|
||||
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id || id === ich.benutzerId) return;
|
||||
|
||||
// Der letzte Administrator bleibt stehen – sonst kommt niemand mehr in die Verwaltung.
|
||||
const admins = await prisma.benutzer.count({ where: { rolle: "ADMIN", aktiv: true } });
|
||||
const betroffen = await prisma.benutzer.findUnique({ where: { id }, select: { rolle: true, email: true } });
|
||||
if (!betroffen) return;
|
||||
if (betroffen.rolle === "ADMIN" && admins <= 1) return;
|
||||
|
||||
await prisma.benutzer.delete({ where: { id } });
|
||||
await protokolliere("benutzer.geloescht", "Benutzer", id, { email: betroffen.email });
|
||||
revalidatePath("/einstellungen/benutzer");
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function einstellungenSpeichernAktion(
|
||||
_zustand: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("verwaltung");
|
||||
|
||||
const vorher = await einstellungen();
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function einstellungenSpeichernAktion(
|
||||
anmeldeversuche: zahl(formular, "anmeldeversuche"),
|
||||
oidcDomaenen: text(formular, "oidcDomaenen"),
|
||||
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
|
||||
oidcStandardRolle: text(formular, "oidcStandardRolle") || vorher.oidcStandardRolle,
|
||||
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
|
||||
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Speichert Notizen und Tags eines Hosts. */
|
||||
@@ -11,10 +11,20 @@ export async function hostNotizenSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("mitarbeiten");
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return { fehler: "Host nicht gefunden." };
|
||||
|
||||
// Kundenkonten dürfen Notizen pflegen – aber nur an den eigenen Hosts.
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
if (nurKunde) {
|
||||
const eigener = await prisma.host.findFirst({
|
||||
where: { id: hostId, kundeId: nurKunde },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!eigener) return { fehler: "Dieser Host gehört nicht zu Ihrem Zugang." };
|
||||
}
|
||||
|
||||
const notizen = formular.get("notizen")?.toString().trim() || null;
|
||||
const tags = (formular.get("tags")?.toString() ?? "")
|
||||
.split(",")
|
||||
@@ -41,7 +51,7 @@ export async function hostNotizenSpeichern(
|
||||
|
||||
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
|
||||
export async function hostKundeSetzen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
||||
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
@@ -32,7 +32,7 @@ export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData
|
||||
|
||||
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
|
||||
export async function hostZuordnungAufheben(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const kundeSchema = z.object({
|
||||
|
||||
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
||||
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = kundeSchema.safeParse({
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
@@ -68,7 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
||||
}
|
||||
|
||||
export async function kundeLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -94,7 +94,7 @@ export async function standortSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = standortSchema.safeParse({
|
||||
kundeId: formular.get("kundeId")?.toString() ?? "",
|
||||
@@ -120,7 +120,7 @@ export async function standortSpeichern(
|
||||
}
|
||||
|
||||
export async function standortLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = formular.get("id")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
/** Entfernt einen eigenen Passkey. Fremde bleiben unberührt – auch für Administratoren. */
|
||||
export async function passkeyLoeschen(formular: FormData) {
|
||||
const ich = await sitzungErforderlich();
|
||||
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
const ergebnis = await prisma.passkey.deleteMany({ where: { id, benutzerId: ich.benutzerId } });
|
||||
if (ergebnis.count === 0) return;
|
||||
|
||||
await protokolliere("passkey.entfernt", "Passkey", id, {});
|
||||
revalidatePath("/einstellungen/passkeys");
|
||||
}
|
||||
@@ -49,7 +49,7 @@ async function ausfuehren(arbeit: () => Promise<string | void>): Promise<Aktions
|
||||
}
|
||||
|
||||
export async function laufzeitBestaetigenAktion(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = formular.get("laufzeitId")?.toString();
|
||||
if (!id) return;
|
||||
await laufzeitBestaetigen(id);
|
||||
@@ -57,7 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
|
||||
}
|
||||
|
||||
export async function laufzeitVerwerfenAktion(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = formular.get("laufzeitId")?.toString();
|
||||
if (!id) return;
|
||||
await laufzeitVerwerfen(id);
|
||||
@@ -66,7 +66,7 @@ export async function laufzeitVerwerfenAktion(formular: FormData) {
|
||||
|
||||
/** Häkchen aus der Rechnungstabelle. */
|
||||
export async function statusUmschalten(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||
const feld = formular.get("feld")?.toString();
|
||||
const wert = formular.get("wert")?.toString() === "true";
|
||||
@@ -88,7 +88,7 @@ export async function proxmoxRechnungSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function kundenrechnungSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
||||
|
||||
@@ -157,7 +157,7 @@ export async function laufzeitAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
const key = formular.get("key")?.toString().trim();
|
||||
const start = datum(formular.get("start"));
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function enrollmentTokenErzeugen(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
@@ -60,7 +60,7 @@ export async function enrollmentTokenErzeugen(
|
||||
}
|
||||
|
||||
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
}
|
||||
|
||||
export async function hostTokenWiderrufen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function mitPasswortAnmelden(
|
||||
*/
|
||||
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
|
||||
const normalisiert = email.toLowerCase().trim();
|
||||
const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen();
|
||||
const { oidcDomaenen, oidcKontenAnlegen, oidcStandardRolle } = await einstellungen();
|
||||
|
||||
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
|
||||
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
|
||||
@@ -94,6 +94,7 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
|
||||
email: normalisiert,
|
||||
name: name || normalisiert,
|
||||
oidcSubject: subject,
|
||||
rolle: oidcStandardRolle,
|
||||
letzterLoginAm: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -101,6 +102,7 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
|
||||
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, {
|
||||
weg: "oidc",
|
||||
neuAngelegt: vorhanden === null,
|
||||
...(vorhanden === null ? { rolle: oidcStandardRolle } : {}),
|
||||
});
|
||||
|
||||
return benutzer;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { oeffentlicheBasis } from "@/lib/adresse";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
/**
|
||||
* Passkeys (WebAuthn) als gleichwertiger Anmeldeweg neben Passwort und OIDC (#028).
|
||||
*
|
||||
* Ein Passkey ist an eine Domäne gebunden – deshalb entscheidet die **öffentliche** Adresse über
|
||||
* `rpID` und `origin`. Steht dort die falsche Adresse, lehnt der Browser die Anmeldung wortlos ab.
|
||||
*
|
||||
* Voraussetzung ist ein sicherer Kontext: HTTPS, oder `localhost` zum Ausprobieren.
|
||||
*/
|
||||
|
||||
export interface Herkunft {
|
||||
rpID: string;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export async function herkunft(request: Request): Promise<Herkunft> {
|
||||
const { dashboardUrl } = await einstellungen();
|
||||
const basis = oeffentlicheBasis(request, dashboardUrl);
|
||||
return { rpID: basis.hostname, origin: basis.origin };
|
||||
}
|
||||
|
||||
/** Ist die Adresse für Passkeys geeignet? Ohne HTTPS geht es nur auf localhost. */
|
||||
export function passkeysMoeglich(origin: string): boolean {
|
||||
try {
|
||||
const adresse = new URL(origin);
|
||||
return (
|
||||
adresse.protocol === "https:" || adresse.hostname === "localhost" || adresse.hostname === "127.0.0.1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registrierungVorbereiten(benutzerId: string, herkunft: Herkunft) {
|
||||
const benutzer = await prisma.benutzer.findUniqueOrThrow({
|
||||
where: { id: benutzerId },
|
||||
include: { passkeys: { select: { credentialId: true, transports: true } } },
|
||||
});
|
||||
|
||||
return generateRegistrationOptions({
|
||||
rpName: "ProxLedger",
|
||||
rpID: herkunft.rpID,
|
||||
userName: benutzer.email,
|
||||
userDisplayName: benutzer.name,
|
||||
// Der Schlüssel soll im Gerät bleiben und ohne Eingabe des Benutzernamens funktionieren.
|
||||
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
||||
// Bereits registrierte Schlüssel ausschließen, damit niemand denselben zweimal anlegt.
|
||||
excludeCredentials: benutzer.passkeys.map((passkey) => ({
|
||||
id: passkey.credentialId,
|
||||
transports: passkey.transports as never,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function registrierungAbschliessen(
|
||||
benutzerId: string,
|
||||
antwort: RegistrationResponseJSON,
|
||||
erwarteteChallenge: string,
|
||||
herkunft: Herkunft,
|
||||
bezeichnung: string,
|
||||
) {
|
||||
const ergebnis = await verifyRegistrationResponse({
|
||||
response: antwort,
|
||||
expectedChallenge: erwarteteChallenge,
|
||||
expectedOrigin: herkunft.origin,
|
||||
expectedRPID: herkunft.rpID,
|
||||
});
|
||||
|
||||
if (!ergebnis.verified || !ergebnis.registrationInfo) {
|
||||
throw new Error("Der Passkey ließ sich nicht überprüfen.");
|
||||
}
|
||||
|
||||
const { credential } = ergebnis.registrationInfo;
|
||||
|
||||
return prisma.passkey.create({
|
||||
data: {
|
||||
benutzerId,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
zaehler: BigInt(credential.counter),
|
||||
transports: credential.transports ?? [],
|
||||
bezeichnung: bezeichnung.trim() || "Passkey",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function anmeldungVorbereiten(herkunft: Herkunft) {
|
||||
return generateAuthenticationOptions({
|
||||
rpID: herkunft.rpID,
|
||||
// Keine Liste erlaubter Schlüssel: Der Browser bietet selbst an, was zu dieser Seite passt.
|
||||
userVerification: "preferred",
|
||||
});
|
||||
}
|
||||
|
||||
export async function anmeldungPruefen(
|
||||
antwort: AuthenticationResponseJSON,
|
||||
erwarteteChallenge: string,
|
||||
herkunft: Herkunft,
|
||||
) {
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { credentialId: antwort.id },
|
||||
include: { benutzer: true },
|
||||
});
|
||||
if (!passkey) throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
|
||||
if (!passkey.benutzer.aktiv) throw new Error("Dieses Konto ist gesperrt.");
|
||||
|
||||
const ergebnis = await verifyAuthenticationResponse({
|
||||
response: antwort,
|
||||
expectedChallenge: erwarteteChallenge,
|
||||
expectedOrigin: herkunft.origin,
|
||||
expectedRPID: herkunft.rpID,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: new Uint8Array(passkey.publicKey),
|
||||
counter: Number(passkey.zaehler),
|
||||
transports: passkey.transports as never,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ergebnis.verified) throw new Error("Die Anmeldung ließ sich nicht überprüfen.");
|
||||
|
||||
// Der Zähler steigt bei jeder Nutzung; ein Rückschritt wäre ein Hinweis auf einen kopierten
|
||||
// Schlüssel. simplewebauthn prüft das bereits – hier wird der neue Stand nur festgehalten.
|
||||
await prisma.passkey.update({
|
||||
where: { id: passkey.id },
|
||||
data: {
|
||||
zaehler: BigInt(ergebnis.authenticationInfo.newCounter),
|
||||
zuletztAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return passkey.benutzer;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Wer darf was (#027).
|
||||
*
|
||||
* Die Rechte stehen bewusst als Liste an einer Stelle – nicht verteilt über Seiten und Aktionen.
|
||||
* Wer eine neue Aktion baut, sucht sich hier das passende Recht und prüft es, statt Rollen
|
||||
* einzeln abzufragen. Ein Recht kommt so nie versehentlich doppelt und unterschiedlich vor.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
/** Hosts, Kunden, Laufzeiten, Rechnungen anlegen und ändern. */
|
||||
fachlichSchreiben: ["ADMIN", "TECHNIKER"],
|
||||
/** Notizen an einem Host und Quittieren einer Warnung – auch für Kunden. */
|
||||
mitarbeiten: ["ADMIN", "TECHNIKER", "KUNDE"],
|
||||
/** Agent-Tokens, Benachrichtigungen, Warnregeln. */
|
||||
betrieb: ["ADMIN", "TECHNIKER"],
|
||||
/** Systemeinstellungen, Benutzerverwaltung, Protokoll. */
|
||||
verwaltung: ["ADMIN"],
|
||||
} as const satisfies Record<string, readonly Rolle[]>;
|
||||
|
||||
export type Recht = keyof typeof RECHTE;
|
||||
|
||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
|
||||
}
|
||||
|
||||
export const ROLLEN_TEXT: Record<Rolle, string> = {
|
||||
ADMIN: "Administrator",
|
||||
TECHNIKER: "Techniker",
|
||||
KUNDE: "Kunde",
|
||||
};
|
||||
|
||||
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
|
||||
ADMIN: "Darf alles, einschließlich Benutzerverwaltung und Systemeinstellungen.",
|
||||
TECHNIKER: "Darf alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer anlegen.",
|
||||
KUNDE: "Sieht nur die eigenen Hosts und Rechnungen, ohne Einkaufspreise. Darf Notizen schreiben und Warnungen quittieren.",
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { darf, type Recht } from "./rollen";
|
||||
|
||||
/**
|
||||
* Verlangt eine gültige Sitzung und prüft sie gegen die Datenbank.
|
||||
@@ -10,9 +13,16 @@ import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzu
|
||||
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
|
||||
* verlassen (OWASP A01 – Broken Access Control).
|
||||
*
|
||||
* Zusätzlich wirkt hier der Widerruf: Ein gesperrtes Konto oder ein zurückgesetztes
|
||||
* Passwort beendet laufende Sitzungen sofort, statt bis zum Ablauf des Tokens zu warten.
|
||||
* Rolle und Kundenbindung kommen **aus der Datenbank**, nicht aus dem Token: Eine geänderte
|
||||
* Rolle wirkt damit sofort und nicht erst nach Ablauf der Sitzung.
|
||||
*/
|
||||
|
||||
export interface AngemeldeterBenutzer extends GeleseneSitzung {
|
||||
rolle: Rolle;
|
||||
/** Nur bei der Rolle KUNDE gesetzt: der Kunde, dessen Daten sichtbar sind. */
|
||||
kundeId: string | null;
|
||||
}
|
||||
|
||||
/** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
|
||||
export function sitzungBewerten(
|
||||
sitzung: GeleseneSitzung | null,
|
||||
@@ -27,18 +37,62 @@ export function sitzungBewerten(
|
||||
return { gueltig: true };
|
||||
}
|
||||
|
||||
export async function sitzungErforderlich(): Promise<GeleseneSitzung> {
|
||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
/**
|
||||
* Liest die aktuelle Anmeldung, ohne weiterzuleiten – für Stellen, die auch ohne Sitzung
|
||||
* funktionieren müssen (Worker, Skripte, Audit-Log). `cache` sorgt dafür, dass eine Anfrage
|
||||
* die Datenbank nur einmal dafür befragt.
|
||||
*/
|
||||
export const angemeldet = cache(async (): Promise<AngemeldeterBenutzer | null> => {
|
||||
let sitzung: GeleseneSitzung | null = null;
|
||||
try {
|
||||
sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
} catch {
|
||||
// Außerhalb einer Anfrage gibt es keine Cookies – das ist kein Fehler.
|
||||
return null;
|
||||
}
|
||||
if (!sitzung) return null;
|
||||
|
||||
const benutzer = sitzung
|
||||
? await prisma.benutzer.findUnique({
|
||||
where: { id: sitzung.benutzerId },
|
||||
select: { aktiv: true, sitzungenUngueltigAb: true },
|
||||
})
|
||||
: null;
|
||||
const benutzer = await prisma.benutzer.findUnique({
|
||||
where: { id: sitzung.benutzerId },
|
||||
select: { aktiv: true, sitzungenUngueltigAb: true, rolle: true, kundeId: true },
|
||||
});
|
||||
|
||||
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||
if (!urteil.gueltig) redirect(`/login?fehler=${encodeURIComponent(urteil.grund)}`);
|
||||
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return null;
|
||||
|
||||
return sitzung as GeleseneSitzung;
|
||||
return { ...sitzung, rolle: benutzer.rolle, kundeId: benutzer.kundeId };
|
||||
});
|
||||
|
||||
/**
|
||||
* Verlangt eine Anmeldung und – falls angegeben – ein bestimmtes Recht.
|
||||
* Ohne Anmeldung geht es zur Anmeldeseite, bei fehlendem Recht auf die Startseite.
|
||||
*/
|
||||
export async function sitzungErforderlich(recht?: Recht): Promise<AngemeldeterBenutzer> {
|
||||
const benutzer = await angemeldet();
|
||||
if (!benutzer) redirect("/login?fehler=Bitte+anmelden.");
|
||||
|
||||
if (recht && !darf(benutzer.rolle, recht)) {
|
||||
redirect("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung.");
|
||||
}
|
||||
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf welchen Kunden die Sicht eingeschränkt ist – `null` heißt: alles sichtbar.
|
||||
*
|
||||
* Die Abfragen rufen das selbst auf, statt sich eine Einschränkung von der Seite reichen zu lassen.
|
||||
* So kann keine Seite vergessen, sie weiterzugeben – der häufigste Weg, wie solche Sichten lecken.
|
||||
*/
|
||||
export async function sichtbarerKunde(): Promise<string | null> {
|
||||
const benutzer = await angemeldet();
|
||||
if (!benutzer || benutzer.rolle !== "KUNDE") return null;
|
||||
|
||||
// Ein Kundenkonto ohne Kundenbindung darf nichts sehen – nicht alles.
|
||||
return benutzer.kundeId ?? "kein-kunde-zugeordnet";
|
||||
}
|
||||
|
||||
/** Dürfen Einkaufspreise und Marge angezeigt werden? */
|
||||
export async function darfEinkaufSehen(): Promise<boolean> {
|
||||
const benutzer = await angemeldet();
|
||||
return benutzer?.rolle !== "KUNDE";
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export const einstellungenSchema = z.object({
|
||||
oidcDomaenen: z.string().default(""),
|
||||
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
|
||||
oidcKontenAnlegen: z.boolean().default(true),
|
||||
/**
|
||||
* Rolle für automatisch angelegte OIDC-Konten. Vorgabe ist bewusst TECHNIKER und nicht ADMIN:
|
||||
* Wer sich beim Identitätsanbieter anmelden kann, soll nicht nebenbei Administrator werden (#027).
|
||||
*/
|
||||
oidcStandardRolle: z.enum(["ADMIN", "TECHNIKER", "KUNDE"]).default("TECHNIKER"),
|
||||
/** 0 = unbegrenzt aufbewahren. */
|
||||
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
|
||||
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
import { meldungsGrenze } from "./hosts";
|
||||
|
||||
/**
|
||||
@@ -7,8 +8,11 @@ import { meldungsGrenze } from "./hosts";
|
||||
* denn deren Rechnungen sind offen – nicht die der laufenden Periode.
|
||||
*/
|
||||
export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
const einkaufSichtbar = await darfEinkaufSehen();
|
||||
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { subNextduedate: { not: null } },
|
||||
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||
orderBy: { subNextduedate: "asc" },
|
||||
include: {
|
||||
kunde: { select: { id: true, name: true } },
|
||||
@@ -40,7 +44,8 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
subKey: host.subKey,
|
||||
nextduedate: ablauf,
|
||||
meldetNicht: !host.letzterReportAm || host.letzterReportAm < grenze,
|
||||
einkaufBezahlt: laufzeit?.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
|
||||
einkaufBezahlt:
|
||||
einkaufSichtbar && laufzeit?.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
|
||||
verkaufErstellt: laufzeit?.kundenPosition?.rechnung.erstellt ?? false,
|
||||
verkaufBezahlt: laufzeit?.kundenPosition?.rechnung.bezahlt ?? false,
|
||||
};
|
||||
@@ -53,6 +58,9 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
* Liste steht unter „Rechnungen“.
|
||||
*/
|
||||
export async function offeneAufgaben(grenze = 12) {
|
||||
// Die Aufgabenliste ist der Arbeitsvorrat des Systemhauses – für Kundenkonten bleibt sie leer.
|
||||
if (await sichtbarerKunde()) return { aufgaben: [], gesamt: 0 };
|
||||
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { meldungsGrenze } from "./hosts";
|
||||
import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export interface HostFilter {
|
||||
q?: string;
|
||||
@@ -98,7 +99,7 @@ const SORTIERUNGEN: Record<string, Prisma.HostOrderByWithRelationInput> = {
|
||||
};
|
||||
|
||||
export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
|
||||
const where = bedingung(filter, jetzt);
|
||||
const where = await eingeschraenkt(bedingung(filter, jetzt));
|
||||
const seite = Math.max(1, filter.seite ?? 1);
|
||||
const orderBy = SORTIERUNGEN[filter.sortierung ?? "hostname"] ?? SORTIERUNGEN.hostname;
|
||||
|
||||
@@ -133,10 +134,23 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schränkt eine Host-Bedingung auf den Kunden ein, dessen Konto angemeldet ist.
|
||||
* Für Admin und Techniker bleibt sie unverändert.
|
||||
*/
|
||||
export async function eingeschraenkt(where: Prisma.HostWhereInput): Promise<Prisma.HostWhereInput> {
|
||||
const kundeId = await sichtbarerKunde();
|
||||
return kundeId ? { AND: [where, { kundeId }] } : where;
|
||||
}
|
||||
|
||||
/** Alle Daten der Host-Detailansicht in einem Rutsch. */
|
||||
export async function hostMitDetails(id: string) {
|
||||
return prisma.host.findUnique({
|
||||
where: { id },
|
||||
const kundeId = await sichtbarerKunde();
|
||||
|
||||
return prisma.host.findFirst({
|
||||
// findFirst statt findUnique: So lässt sich die Kundenbindung in dieselbe Abfrage legen,
|
||||
// statt den Host erst zu laden und danach wegzuwerfen.
|
||||
where: kundeId ? { id, kundeId } : { id },
|
||||
include: {
|
||||
kunde: { select: { id: true, name: true } },
|
||||
standort: { select: { id: true, name: true, ort: true } },
|
||||
@@ -161,7 +175,9 @@ export type HostDetail = NonNullable<Awaited<ReturnType<typeof hostMitDetails>>>
|
||||
|
||||
/** Auswahllisten für die Filterleiste. */
|
||||
export async function filterOptionen() {
|
||||
const kundeId = await sichtbarerKunde();
|
||||
const kunden = await prisma.kunde.findMany({
|
||||
where: kundeId ? { id: kundeId } : {},
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
+49
-22
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
/** Frist, nach der ein Host als „meldet sich nicht“ gilt (konfigurierbar ab #011). */
|
||||
export const MELDUNG_FRIST_STUNDEN = 48;
|
||||
@@ -8,6 +9,10 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
|
||||
}
|
||||
|
||||
export async function hostsOhneKunde() {
|
||||
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
|
||||
// noch niemandem, dürfen also auch niemandem gezeigt werden.
|
||||
if (await sichtbarerKunde()) return [];
|
||||
|
||||
return prisma.host.findMany({
|
||||
where: { kundeId: null },
|
||||
orderBy: { erstmalsGesehenAm: "desc" },
|
||||
@@ -15,35 +20,48 @@ export async function hostsOhneKunde() {
|
||||
}
|
||||
|
||||
export async function hostAnzahl() {
|
||||
return prisma.host.count();
|
||||
const kundeId = await sichtbarerKunde();
|
||||
return prisma.host.count({ where: kundeId ? { kundeId } : {} });
|
||||
}
|
||||
|
||||
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
|
||||
export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||
const kundeId = await sichtbarerKunde();
|
||||
const hostSicht = kundeId ? { kundeId } : {};
|
||||
const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
|
||||
|
||||
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
||||
prisma.host.count(),
|
||||
prisma.host.count({ where: { subStatus: "ACTIVE" } }),
|
||||
prisma.host.count({ where: { kundeId: null } }),
|
||||
prisma.host.count({ where: { subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
||||
prisma.host.count({ where: hostSicht }),
|
||||
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
|
||||
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }),
|
||||
prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
||||
prisma.host.count({
|
||||
where: { OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }] },
|
||||
where: {
|
||||
...hostSicht,
|
||||
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
|
||||
},
|
||||
}),
|
||||
prisma.alert.count({ where: { erledigtAm: null } }),
|
||||
prisma.alert.count({ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) } }),
|
||||
]);
|
||||
|
||||
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
|
||||
prisma.laufzeit.count({
|
||||
where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
|
||||
}),
|
||||
prisma.laufzeit.count({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPosition: { isNot: null },
|
||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||
},
|
||||
}),
|
||||
// Einkaufsrechnungen gehen ein Kundenkonto nichts an.
|
||||
kundeId
|
||||
? Promise.resolve(0)
|
||||
: prisma.laufzeit.count({
|
||||
where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
|
||||
}),
|
||||
kundeId
|
||||
? Promise.resolve(0)
|
||||
: prisma.laufzeit.count({
|
||||
where: {
|
||||
...laufzeitSicht,
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPosition: { isNot: null },
|
||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -61,8 +79,10 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
}
|
||||
|
||||
export async function offeneAlerts(anzahl = 20) {
|
||||
const kundeId = await sichtbarerKunde();
|
||||
|
||||
return prisma.alert.findMany({
|
||||
where: { erledigtAm: null },
|
||||
where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) },
|
||||
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
|
||||
take: anzahl,
|
||||
});
|
||||
@@ -70,8 +90,11 @@ export async function offeneAlerts(anzahl = 20) {
|
||||
|
||||
/** Einträge für die globale Suche (Strg+K). */
|
||||
export async function suchIndex() {
|
||||
const kundeId = await sichtbarerKunde();
|
||||
|
||||
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: kundeId ? { kundeId } : {},
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
@@ -84,13 +107,17 @@ export async function suchIndex() {
|
||||
},
|
||||
}),
|
||||
prisma.kunde.findMany({
|
||||
where: kundeId ? { id: kundeId } : {},
|
||||
select: { id: true, name: true, kundennummer: true, ansprechpartner: true, email: true },
|
||||
}),
|
||||
prisma.proxmoxRechnung.findMany({
|
||||
select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
|
||||
}),
|
||||
// Proxmox-Rechnungen sind Einkauf – für ein Kundenkonto gibt es sie schlicht nicht.
|
||||
kundeId
|
||||
? Promise.resolve([])
|
||||
: prisma.proxmoxRechnung.findMany({
|
||||
select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
|
||||
}),
|
||||
prisma.kundenrechnung.findMany({
|
||||
where: { nummer: { not: null } },
|
||||
where: { nummer: { not: null }, ...(kundeId ? { kundeId } : {}) },
|
||||
select: { id: true, nummer: true, kunde: { select: { id: true, name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
/** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */
|
||||
export async function ablaufDaten(jetzt: Date = new Date()) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { subNextduedate: { not: null } },
|
||||
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||
orderBy: [{ subNextduedate: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
/** Abfragen rund um Kunden. Beträge kommen als Decimal aus der Datenbank und werden hier zu Zahlen. */
|
||||
|
||||
export async function kundenListe() {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
|
||||
const kunden = await prisma.kunde.findMany({
|
||||
where: nurKunde ? { id: nurKunde } : {},
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
standorte: true,
|
||||
@@ -42,6 +46,9 @@ export async function kundenListe() {
|
||||
}
|
||||
|
||||
export async function kundeMitDetails(id: string) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
if (nurKunde && nurKunde !== id) return null;
|
||||
|
||||
return prisma.kunde.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -54,6 +61,10 @@ export async function kundeMitDetails(id: string) {
|
||||
|
||||
/** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */
|
||||
export async function laufzeitenVonKunde(kundeId: string) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
if (nurKunde && nurKunde !== kundeId) return [];
|
||||
const einkaufSichtbar = await darfEinkaufSehen();
|
||||
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: { host: { kundeId } },
|
||||
orderBy: { start: "desc" },
|
||||
@@ -72,7 +83,8 @@ export async function laufzeitenVonKunde(kundeId: string) {
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
vorschlag: laufzeit.status === "VORSCHLAG",
|
||||
einkauf: laufzeit.proxmoxPosition
|
||||
einkauf:
|
||||
einkaufSichtbar && laufzeit.proxmoxPosition
|
||||
? {
|
||||
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
||||
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export interface RechnungsFilter {
|
||||
status?: "offen" | "erledigt" | "alle" | "vorschlaege";
|
||||
@@ -58,8 +59,12 @@ function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput {
|
||||
}
|
||||
|
||||
export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
const einkaufSichtbar = await darfEinkaufSehen();
|
||||
const where = nurKunde ? { AND: [bedingung(filter), { host: { kundeId: nurKunde } }] } : bedingung(filter);
|
||||
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: bedingung(filter),
|
||||
where,
|
||||
orderBy: [{ start: "desc" }],
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
|
||||
@@ -69,7 +74,10 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
});
|
||||
|
||||
const zeilen = laufzeiten.map((laufzeit) => {
|
||||
const einkauf = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||
// Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
|
||||
// nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML.
|
||||
const einkauf =
|
||||
einkaufSichtbar && laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||
|
||||
return {
|
||||
@@ -81,10 +89,11 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
vorschlag: laufzeit.status === "VORSCHLAG",
|
||||
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
|
||||
einkaufNummer: einkaufSichtbar ? (laufzeit.proxmoxPosition?.rechnung.nummer ?? null) : null,
|
||||
einkaufBetrag: einkauf,
|
||||
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
|
||||
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
|
||||
einkaufBezahlt:
|
||||
einkaufSichtbar && laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
|
||||
einkaufFaelligAm: einkaufSichtbar ? (laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null) : null,
|
||||
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
|
||||
verkaufBetrag: verkauf,
|
||||
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
|
||||
@@ -111,15 +120,22 @@ export type RechnungsZeile = Awaited<ReturnType<typeof rechnungsListe>>["zeilen"
|
||||
|
||||
/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */
|
||||
export async function laufzeitJahre(): Promise<string[]> {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({ select: { start: true } });
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: nurKunde ? { host: { kundeId: nurKunde } } : {},
|
||||
select: { start: true },
|
||||
});
|
||||
return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) =>
|
||||
b.localeCompare(a),
|
||||
);
|
||||
}
|
||||
|
||||
export async function laufzeitDetail(id: string) {
|
||||
return prisma.laufzeit.findUnique({
|
||||
where: { id },
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
const einkaufSichtbar = await darfEinkaufSehen();
|
||||
|
||||
const laufzeit = await prisma.laufzeit.findFirst({
|
||||
where: nurKunde ? { id, host: { kundeId: nurKunde } } : { id },
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
@@ -145,8 +161,14 @@ export async function laufzeitDetail(id: string) {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!laufzeit) return null;
|
||||
// Für Kundenkonten fällt der gesamte Einkaufsteil weg.
|
||||
return einkaufSichtbar ? laufzeit : { ...laufzeit, proxmoxPosition: null };
|
||||
}
|
||||
|
||||
export async function anzahlVorschlaege() {
|
||||
// Vorschläge sind Arbeitsvorrat für das Systemhaus, kein Thema für Kundenkonten.
|
||||
if (await sichtbarerKunde()) return 0;
|
||||
return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user