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
+10 -2
View File
@@ -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",
+19 -3
View File
@@ -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
View File
@@ -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 } } },
}),
]);
+4 -1
View File
@@ -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,
+13 -1
View File
@@ -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),
+30 -8
View File
@@ -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" } });
}