feat(auth): Rollen, Benutzerverwaltung und Passkeys (#027, #028)

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:
2026-09-19 00:46:29 +02:00
co-authored by Claude Opus 5
parent 7d8e5c9172
commit 2f45e33e20
66 changed files with 2201 additions and 114 deletions
+14 -6
View File
@@ -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[]> = {};
+6 -6
View File
@@ -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();
+165
View File
@@ -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");
}
+2 -1
View File
@@ -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"),
+13 -3
View File
@@ -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;
+2 -2
View File
@@ -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;
+4 -4
View File
@@ -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;
+20
View File
@@ -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");
}
+6 -6
View File
@@ -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"));
+3 -3
View File
@@ -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;