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:
@@ -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