From 8af6b384eabb947cd6e3cfcf52f3753b616e1575 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 11 Aug 2026 13:22:42 +0200 Subject: [PATCH] Benutzerverwaltung, Firmenstamm und Rollenlegende MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Verwaltung war bisher eine reine Anzeige. Sie gliedert sich jetzt in vier Reiter und ist handlungsfähig. Benutzer - Zugänge anlegen, bearbeiten, sperren und entsperren - Startkennwort wird erzeugt und genau einmal angezeigt, auf Wunsch zusätzlich per E-Mail versandt; es liegt nirgends im Klartext - Kennwort zurücksetzen beendet zugleich alle laufenden Sitzungen - Wer sich mit einem Startkennwort anmeldet, wird unmittelbar zur Vergabe eines eigenen Kennworts geführt; bis dahin weist ein Streifen im Kopfbereich darauf hin - Zugänge werden gesperrt, nicht gelöscht: Bautagebucheinträge, Prüfvermerke und Bescheinigungen müssen dauerhaft einer Person zuordenbar bleiben Aussperrschutz - der eigene Zugang lässt sich weder sperren noch die eigene Administration entziehen - es muss stets mindestens ein aktiver Administrationszugang bestehen - Firmen mit bestehenden Verträgen lassen sich nicht stilllegen Firmen - Stammdaten mit Art, Anschrift, Kreditorennummer und USt-IdNr., stilllegbar Rollen und Rechte - Legende aller Rollen, getrennt nach Auftraggeber- und Auftragnehmerseite - vollständige Rechtematrix, unmittelbar aus PERMISSIONS erzeugt und damit nicht von der Wirklichkeit im Code trennbar - Übersicht der Projektschalter und der fest verdrahteten Sperren Protokoll - Filter nach Aktion und Person, Blätterung, haftungsrelevante Vorgänge hervorgehoben Nebenbei behoben - next.config: "output: standalone" entfernt, weil es sich mit "next start" nicht verträgt; nicht mehr unterstützten eslint-Schlüssel entfernt - Unterlagen des Auftraggebers (*.xlsx) gehören nicht ins Repository Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 36 +- app/next.config.mjs | 5 +- app/src/app/(app)/layout.tsx | 13 + app/src/app/(app)/profil/page.tsx | 16 +- .../app/(app)/verwaltung/Benutzerliste.tsx | 342 ++++++++++++++++++ app/src/app/(app)/verwaltung/actions.ts | 236 ++++++++++++ .../(app)/verwaltung/firmen/Firmenliste.tsx | 240 ++++++++++++ app/src/app/(app)/verwaltung/firmen/page.tsx | 39 ++ app/src/app/(app)/verwaltung/layout.tsx | 63 ++++ app/src/app/(app)/verwaltung/page.tsx | 166 ++------- .../app/(app)/verwaltung/protokoll/page.tsx | 169 +++++++++ app/src/app/(app)/verwaltung/rollen/page.tsx | 218 +++++++++++ app/src/app/anmelden/page.tsx | 3 +- app/src/lib/auth.ts | 4 +- app/src/lib/rbac.ts | 84 +++++ 15 files changed, 1495 insertions(+), 139 deletions(-) create mode 100644 app/src/app/(app)/verwaltung/Benutzerliste.tsx create mode 100644 app/src/app/(app)/verwaltung/actions.ts create mode 100644 app/src/app/(app)/verwaltung/firmen/Firmenliste.tsx create mode 100644 app/src/app/(app)/verwaltung/firmen/page.tsx create mode 100644 app/src/app/(app)/verwaltung/layout.tsx create mode 100644 app/src/app/(app)/verwaltung/protokoll/page.tsx create mode 100644 app/src/app/(app)/verwaltung/rollen/page.tsx diff --git a/README.md b/README.md index e607218..8101c34 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ Bewusst schmal gehalten – vier bewegliche Teile, keine Fremddienste: - **Eigene Session-Authentifizierung** (bcrypt, HttpOnly-Cookie) statt zusätzlichem Auth-Dienst - **Nodemailer** gegen ein beliebiges SMTP-Relais; im Testbetrieb Mailpit +Kennwörter liegen als bcrypt-Hash (Kostenfaktor 11). Sitzungen sind serverseitig in der +Datenbank hinterlegt und lassen sich dadurch gezielt beenden – beim Sperren eines Zugangs, +beim Zurücksetzen eines Kennworts und bei jeder Kennwortänderung. + ### Persistenz ohne Named Volumes Alle persistenten Daten liegen als Bind-Mount direkt im Compose-Verzeichnis: @@ -78,7 +82,7 @@ SHA-256-Prüfsumme in der Datenbank, nachträgliche Veränderungen sind erkennba | **Stunden & Aufmaß** | Auftragnehmer reicht ein, Bauüberwachung erkennt **zeilenweise** an – das ist die Messlatte der Rechnungsprüfung | | **Rechnungsprüfung** | Automatischer Abgleich gegen Vertrag, Aufmaß, Stundenzettel und Vorrechnungen; getrennte Bescheinigung *sachlich richtig* / *rechnerisch richtig* / Zahlungsfreigabe | | **Kommunikation** | Projektkanäle statt E-Mail-Pingpong, `@Nachname`-Erwähnungen, E-Mail-Benachrichtigung je nach Profileinstellung | -| **Verwaltung** | Benutzer, Firmen, revisionssicheres Protokoll aller Vorgänge | +| **Verwaltung** | Zugänge anlegen und sperren, Kennwörter zurücksetzen, Firmenstamm, Rollenlegende, revisionssicheres Protokoll | ### Rechnungsprüfung – geprüfte Befunde @@ -226,6 +230,36 @@ Soll-Katalog nachziehen* fehlende Positionen nach und rechnet die Fristen aller offenen Positionen neu. Einmal angelegte Positionen werden nie gelöscht – nicht mehr zutreffende setzt man einzeln auf „entfällt“, dann bleibt der Grund dokumentiert. +### Benutzerverwaltung + +Unter *Verwaltung* (nur für Zugänge mit Administrationsrecht), in vier Reitern: + +| Reiter | Inhalt | +|---|---| +| **Benutzer** | Zugänge anlegen, bearbeiten, sperren und entsperren, Kennwort zurücksetzen | +| **Firmen** | Firmenstamm mit Art, Anschrift, Kreditorennummer und USt-IdNr. | +| **Rollen & Rechte** | Legende aller Rollen und die vollständige Rechtematrix – unmittelbar aus `rbac.ts` erzeugt und damit nie veraltet | +| **Protokoll** | Alle Vorgänge mit Filter nach Aktion und Person | + +**Kennwörter.** Beim Anlegen wird ein Startkennwort erzeugt und **genau einmal** angezeigt +(auf Wunsch zusätzlich per E-Mail versandt). Es ist nirgends im Klartext gespeichert. Wer +sich damit anmeldet, landet unmittelbar auf der Profilseite und muss ein eigenes Kennwort +vergeben; bis dahin weist ein Streifen im Kopfbereich darauf hin. Ein Zurücksetzen durch die +Administration beendet zugleich alle laufenden Sitzungen. + +**Zugänge werden gesperrt, nicht gelöscht.** Bautagebucheinträge, Prüfvermerke und die +Bescheinigungen *sachlich richtig* und *rechnerisch richtig* müssen auch nach Jahren einer +Person zuordenbar bleiben – bei bis zu 38 Jahren Aufbewahrung nach Bauschlussmeldung ist das +keine Nebensache. Sperren beendet sofort alle Sitzungen. + +**Aussperrschutz.** Der eigene Zugang kann weder gesperrt noch der eigenen Administration +entzogen werden, und es muss stets mindestens ein aktiver Administrationszugang bestehen. +Firmen mit bestehenden Verträgen lassen sich nicht stilllegen. + +**Projektrollen werden nicht hier vergeben**, sondern im jeweiligen Projekt unter +*Einstellungen → Beteiligte*. Das Administrationsrecht ist davon unabhängig und wirkt +projektübergreifend – entsprechend sparsam vergeben. + ### Absichtliche Sperren Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache: diff --git a/app/next.config.mjs b/app/next.config.mjs index 615bfab..9d66d61 100644 --- a/app/next.config.mjs +++ b/app/next.config.mjs @@ -1,11 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: "standalone", + // Kein "output: standalone": das Laufzeit-Image bringt node_modules mit und + // startet über "next start". Beides zusammen verträgt sich nicht. experimental: { serverActions: { bodySizeLimit: "250mb" }, }, - eslint: { ignoreDuringBuilds: true }, - typescript: { ignoreBuildErrors: false }, }; export default nextConfig; diff --git a/app/src/app/(app)/layout.tsx b/app/src/app/(app)/layout.tsx index 1275fb4..2977403 100644 --- a/app/src/app/(app)/layout.tsx +++ b/app/src/app/(app)/layout.tsx @@ -76,6 +76,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod + {user.mustChangePw && ( +
+
+ + Ihr Zugang läuft noch mit dem Startkennwort der Administration. + + + Jetzt eigenes Kennwort vergeben + +
+
+ )} +
{children}
); diff --git a/app/src/app/(app)/profil/page.tsx b/app/src/app/(app)/profil/page.tsx index 46a3f47..0789cea 100644 --- a/app/src/app/(app)/profil/page.tsx +++ b/app/src/app/(app)/profil/page.tsx @@ -6,7 +6,11 @@ import { datumZeit } from "@/lib/format"; export const dynamic = "force-dynamic"; -export default async function Profil({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) { +export default async function Profil({ + searchParams, +}: { + searchParams: Promise<{ ok?: string; fehler?: string; kennwort?: string }>; +}) { const session = await requireUser(); const sp = await searchParams; @@ -61,6 +65,16 @@ export default async function Profil({ searchParams }: { searchParams: Promise<{

+ {(sp.kennwort === "erforderlich" || user.mustChangePw) && ( +
+

Bitte vergeben Sie ein eigenes Kennwort.

+

+ Ihr Zugang wurde mit einem Startkennwort der Administration eingerichtet. Dieses ist der + Administration bekannt gewesen und sollte nicht dauerhaft verwendet werden. +

+
+ )} + {sp.fehler && (

{sp.fehler === "altes-kennwort" ? "Das bisherige Kennwort ist nicht korrekt." : "Das neue Kennwort ist zu kurz (mindestens 10 Zeichen)."} diff --git a/app/src/app/(app)/verwaltung/Benutzerliste.tsx b/app/src/app/(app)/verwaltung/Benutzerliste.tsx new file mode 100644 index 0000000..6a12b00 --- /dev/null +++ b/app/src/app/(app)/verwaltung/Benutzerliste.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { Fragment, useState, useTransition } from "react"; +import { Check, Copy, KeyRound, Lock, Pencil, Plus, Unlock, UserPlus, X } from "lucide-react"; +import { benutzerAnlegen, benutzerBearbeiten, benutzerStatus, kennwortZuruecksetzen } from "./actions"; +import { StatusChip } from "@/components/ui"; +import { datumZeit } from "@/lib/format"; + +type Benutzer = { + id: string; + email: string; + name: string; + position: string | null; + phone: string | null; + active: boolean; + isAdmin: boolean; + mustChangePw: boolean; + companyId: string | null; + firma: string | null; + lastLoginAt: string | null; + sitzungen: number; + rollen: string[]; + projekte: number; +}; + +type Firma = { id: string; name: string; typ: string }; + +export function Benutzerliste({ + eigeneId, + benutzer, + firmen, +}: { + eigeneId: string; + benutzer: Benutzer[]; + firmen: Firma[]; +}) { + const [neu, setNeu] = useState(false); + const [bearbeitet, setBearbeitet] = useState(null); + const [suche, setSuche] = useState(""); + const [zeigeGesperrte, setZeigeGesperrte] = useState(true); + const [fehler, setFehler] = useState(null); + const [zugang, setZugang] = useState<{ name: string; email: string; kennwort: string } | null>(null); + const [pending, start] = useTransition(); + + const lauf = (fn: () => Promise) => + start(async () => { + setFehler(null); + try { + await fn(); + } catch (e) { + setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich."); + } + }); + + const q = suche.trim().toLowerCase(); + const gefiltert = benutzer.filter( + (b) => + (zeigeGesperrte || b.active) && + (!q || + b.name.toLowerCase().includes(q) || + b.email.toLowerCase().includes(q) || + (b.firma ?? "").toLowerCase().includes(q) || + (b.position ?? "").toLowerCase().includes(q)), + ); + + return ( +

+ {/* ── Ausgabe eines neuen Kennworts ────────────────────── */} + {zugang && ( +
+
+
+

+ Startkennwort für {zugang.name} +

+

+ Es wird nur dieses eine Mal angezeigt und ist nirgends im Klartext gespeichert. + Beim ersten Anmelden muss ein eigenes Kennwort vergeben werden. +

+
+ + {zugang.kennwort} + + + für {zugang.email} +
+
+ +
+
+ )} + + {fehler && ( +

{fehler}

+ )} + + {/* ── Kopfzeile ────────────────────────────────────────── */} +
+ setSuche(e.target.value)} + placeholder="Name, E-Mail, Firma oder Funktion …" + className="input w-80 py-1.5 text-xs" + /> + + {gefiltert.length} von {benutzer.length} + +
+ + {/* ── Neuanlage ────────────────────────────────────────── */} + {neu && ( +
+ lauf(async () => { + const r = await benutzerAnlegen(fd); + setZugang({ name: r.name, email: r.email, kennwort: r.kennwort }); + setNeu(false); + }) + } + > +
+

Neuen Zugang anlegen

+ +
+ + + + + )} + + {/* ── Liste ────────────────────────────────────────────── */} +
+ + + + + + + + + + + + {gefiltert.map((b) => ( + + + + + + + + + + {bearbeitet === b.id && ( + + + + )} + + ))} + +
PersonFirmaProjektrollenLetzte AnmeldungAktion
+
+ {b.name} + {b.isAdmin && Administration} + {!b.active && } + {b.mustChangePw && b.active && ( + + Kennwortwechsel offen + + )} + {b.id === eigeneId && (Sie)} +
+
+ {b.email} + {b.position && ` · ${b.position}`} + {b.phone && ` · ${b.phone}`} +
+
{b.firma ?? "—"} + {b.rollen.length ? b.rollen.join(", ") : keine} + {b.projekte > 0 && ( +
+ in {b.projekte} {b.projekte === 1 ? "Projekt" : "Projekten"} +
+ )} +
+ {b.lastLoginAt ? datumZeit(b.lastLoginAt) : "noch nie"} + {b.sitzungen > 0 &&
{b.sitzungen} Sitzung(en)
} +
+
+ + + {b.id !== eigeneId && ( + + )} +
+
+
+ lauf(async () => { + await benutzerBearbeiten(fd); + setBearbeitet(null); + }) + } + > + + +
+ + +
+ +
+
+ +

+ Zugänge werden gesperrt, nicht gelöscht. Bautagebucheinträge, Prüfvermerke und die + Bescheinigungen „sachlich richtig“ und „rechnerisch richtig“ müssen auch nach Jahren + einer Person zuordenbar bleiben – bei Aufbewahrungsfristen von bis zu 38 Jahren nach + Bauschlussmeldung ist das keine Nebensache. +

+
+ ); +} + +function Felder({ + firmen, + werte, + eigenerZugang = false, +}: { + firmen: Firma[]; + werte?: Benutzer; + eigenerZugang?: boolean; +}) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

+ Projektrollen werden nicht hier, sondern im jeweiligen Projekt unter Einstellungen → + Beteiligte vergeben. Das hält die Zuständigkeit dort, wo sie fachlich hingehört. +

+
+ ); +} diff --git a/app/src/app/(app)/verwaltung/actions.ts b/app/src/app/(app)/verwaltung/actions.ts new file mode 100644 index 0000000..0650110 --- /dev/null +++ b/app/src/app/(app)/verwaltung/actions.ts @@ -0,0 +1,236 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { randomInt } from "crypto"; +import { prisma } from "@/lib/db"; +import { requireUser, hashPassword } from "@/lib/auth"; +import { protokolliere, benachrichtige } from "@/lib/notify"; +import { sendeMail, htmlRahmen } from "@/lib/mail"; +import type { CompanyType } from "@prisma/client"; + +async function nurAdmin() { + const user = await requireUser(); + if (!user.isAdmin) throw new Error("Diese Funktion ist der Administration vorbehalten."); + return user; +} + +/** + * Startkennwort erzeugen. Bewusst gut vorlesbar und fehlerarm – + * es wird typischerweise am Telefon oder auf Papier weitergegeben. + * Ohne 0/O und 1/l/I, damit nichts verwechselt wird. + */ +function startkennwort(): string { + const zeichen = "abcdefghijkmnpqrstuvwxyz"; + const ziffern = "23456789"; + const gross = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const teil = (menge: string, n: number) => + Array.from({ length: n }, () => menge[randomInt(menge.length)]).join(""); + return `${gross[randomInt(gross.length)]}${teil(zeichen, 5)}-${teil(zeichen, 5)}-${teil(ziffern, 4)}`; +} + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +// ═══════════════════════════════════════════════════════════════════ +// Benutzer +// ═══════════════════════════════════════════════════════════════════ + +export async function benutzerAnlegen(formData: FormData) { + const admin = await nurAdmin(); + + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + const name = String(formData.get("name") ?? "").trim(); + if (!EMAIL.test(email)) throw new Error("Bitte eine gültige E-Mail-Adresse angeben."); + if (name.length < 2) throw new Error("Bitte den vollständigen Namen angeben."); + if (await prisma.user.findUnique({ where: { email } })) + throw new Error(`Für „${email}“ besteht bereits ein Zugang.`); + + const kennwort = startkennwort(); + const benutzer = await prisma.user.create({ + data: { + email, + name, + passwordHash: await hashPassword(kennwort), + position: String(formData.get("position") ?? "").trim() || null, + phone: String(formData.get("phone") ?? "").trim() || null, + companyId: String(formData.get("companyId") ?? "") || null, + isAdmin: formData.get("isAdmin") === "on", + mustChangePw: true, + }, + }); + + await protokolliere({ + userId: admin.id, aktion: "BENUTZER_ANGELEGT", entitaet: "User", + entitaetId: benutzer.id, details: { email, isAdmin: benutzer.isAdmin }, + }); + + if (formData.get("mailSenden") === "on") { + const url = process.env.APP_URL || "http://localhost:3400"; + const text = + `Guten Tag ${name},\n\n` + + `für Sie wurde ein Zugang zu BauDoc Bahn eingerichtet.\n\n` + + `Adresse: ${url}\n` + + `Benutzer: ${email}\n` + + `Kennwort: ${kennwort}\n\n` + + `Das Kennwort ist beim ersten Anmelden zu ändern.`; + await sendeMail({ + an: email, + betreff: "Ihr Zugang zu BauDoc Bahn", + text, + html: htmlRahmen("Ihr Zugang zu BauDoc Bahn", text, "/anmelden"), + }); + } + + revalidatePath("/verwaltung"); + return { id: benutzer.id, email, name, kennwort }; +} + +export async function benutzerBearbeiten(formData: FormData) { + const admin = await nurAdmin(); + const id = String(formData.get("userId")); + const vorher = await prisma.user.findUniqueOrThrow({ where: { id } }); + + const istAdminNeu = formData.get("isAdmin") === "on"; + if (id === admin.id && !istAdminNeu) + throw new Error("Sie können sich die eigenen Administrationsrechte nicht entziehen."); + if (vorher.isAdmin && !istAdminNeu) { + const anzahl = await prisma.user.count({ where: { isAdmin: true, active: true } }); + if (anzahl <= 1) throw new Error("Es muss mindestens ein aktiver Administrationszugang bestehen."); + } + + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + if (!EMAIL.test(email)) throw new Error("Bitte eine gültige E-Mail-Adresse angeben."); + if (email !== vorher.email && (await prisma.user.findUnique({ where: { email } }))) + throw new Error(`Für „${email}“ besteht bereits ein Zugang.`); + + await prisma.user.update({ + where: { id }, + data: { + email, + name: String(formData.get("name") ?? "").trim(), + position: String(formData.get("position") ?? "").trim() || null, + phone: String(formData.get("phone") ?? "").trim() || null, + companyId: String(formData.get("companyId") ?? "") || null, + isAdmin: istAdminNeu, + }, + }); + + await protokolliere({ + userId: admin.id, aktion: "BENUTZER_GEAENDERT", entitaet: "User", + entitaetId: id, details: { email, isAdmin: istAdminNeu }, + }); + revalidatePath("/verwaltung"); +} + +/** + * Zugänge werden gesperrt, nicht gelöscht: Bautagebucheinträge, Prüfvermerke + * und Bescheinigungen müssen dauerhaft einer Person zuordenbar bleiben. + */ +export async function benutzerStatus(userId: string, aktiv: boolean) { + const admin = await nurAdmin(); + const benutzer = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + + if (userId === admin.id && !aktiv) throw new Error("Sie können den eigenen Zugang nicht sperren."); + if (benutzer.isAdmin && !aktiv) { + const anzahl = await prisma.user.count({ where: { isAdmin: true, active: true } }); + if (anzahl <= 1) throw new Error("Es muss mindestens ein aktiver Administrationszugang bestehen."); + } + + await prisma.user.update({ where: { id: userId }, data: { active: aktiv } }); + if (!aktiv) await prisma.session.deleteMany({ where: { userId } }); // laufende Sitzungen beenden + + await protokolliere({ + userId: admin.id, aktion: aktiv ? "BENUTZER_ENTSPERRT" : "BENUTZER_GESPERRT", + entitaet: "User", entitaetId: userId, details: { email: benutzer.email }, + }); + revalidatePath("/verwaltung"); +} + +export async function kennwortZuruecksetzen(userId: string, perMail: boolean) { + const admin = await nurAdmin(); + const benutzer = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + + const kennwort = startkennwort(); + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash: await hashPassword(kennwort), mustChangePw: true }, + }); + await prisma.session.deleteMany({ where: { userId } }); + + await protokolliere({ + userId: admin.id, aktion: "KENNWORT_ZURUECKGESETZT", entitaet: "User", + entitaetId: userId, details: { email: benutzer.email, perMail }, + }); + + if (perMail) { + const url = process.env.APP_URL || "http://localhost:3400"; + const text = + `Guten Tag ${benutzer.name},\n\n` + + `Ihr Kennwort für BauDoc Bahn wurde von der Administration zurückgesetzt.\n\n` + + `Adresse: ${url}\n` + + `Benutzer: ${benutzer.email}\n` + + `Kennwort: ${kennwort}\n\n` + + `Bitte beim nächsten Anmelden ein eigenes Kennwort vergeben.`; + await sendeMail({ + an: benutzer.email, + betreff: "Ihr Kennwort für BauDoc Bahn wurde zurückgesetzt", + text, + html: htmlRahmen("Kennwort zurückgesetzt", text, "/anmelden"), + }); + } + + revalidatePath("/verwaltung"); + return { name: benutzer.name, email: benutzer.email, kennwort }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Firmen +// ═══════════════════════════════════════════════════════════════════ + +export async function firmaSpeichern(formData: FormData) { + const admin = await nurAdmin(); + const id = String(formData.get("companyId") ?? ""); + + const daten = { + name: String(formData.get("name") ?? "").trim(), + shortName: String(formData.get("shortName") ?? "").trim() || null, + type: (formData.get("type") as CompanyType) || "SONSTIGE", + street: String(formData.get("street") ?? "").trim() || null, + zip: String(formData.get("zip") ?? "").trim() || null, + city: String(formData.get("city") ?? "").trim() || null, + ustId: String(formData.get("ustId") ?? "").trim() || null, + kreditorNr: String(formData.get("kreditorNr") ?? "").trim() || null, + contactEmail: String(formData.get("contactEmail") ?? "").trim() || null, + contactPhone: String(formData.get("contactPhone") ?? "").trim() || null, + notes: String(formData.get("notes") ?? "").trim() || null, + }; + if (daten.name.length < 2) throw new Error("Bitte den Firmennamen angeben."); + + const firma = id + ? await prisma.company.update({ where: { id }, data: daten }) + : await prisma.company.create({ data: daten }); + + await protokolliere({ + userId: admin.id, aktion: id ? "FIRMA_GEAENDERT" : "FIRMA_ANGELEGT", + entitaet: "Company", entitaetId: firma.id, details: { name: daten.name }, + }); + revalidatePath("/verwaltung/firmen"); +} + +export async function firmaStatus(companyId: string, aktiv: boolean) { + const admin = await nurAdmin(); + const firma = await prisma.company.findUniqueOrThrow({ + where: { id: companyId }, + include: { _count: { select: { users: true, contracts: true } } }, + }); + if (!aktiv && firma._count.contracts > 0) + throw new Error( + `„${firma.name}“ hat ${firma._count.contracts} Verträge und kann nicht stillgelegt werden.`, + ); + + await prisma.company.update({ where: { id: companyId }, data: { active: aktiv } }); + await protokolliere({ + userId: admin.id, aktion: aktiv ? "FIRMA_AKTIV" : "FIRMA_STILLGELEGT", + entitaet: "Company", entitaetId: companyId, details: { name: firma.name }, + }); + revalidatePath("/verwaltung/firmen"); +} diff --git a/app/src/app/(app)/verwaltung/firmen/Firmenliste.tsx b/app/src/app/(app)/verwaltung/firmen/Firmenliste.tsx new file mode 100644 index 0000000..3e5466d --- /dev/null +++ b/app/src/app/(app)/verwaltung/firmen/Firmenliste.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { Fragment, useState, useTransition } from "react"; +import { Building2, Check, Pencil, Power, Plus, X } from "lucide-react"; +import { firmaSpeichern, firmaStatus } from "../actions"; +import { StatusChip } from "@/components/ui"; + +type Firma = { + id: string; + name: string; + shortName: string | null; + type: string; + street: string | null; + zip: string | null; + city: string | null; + ustId: string | null; + kreditorNr: string | null; + contactEmail: string | null; + contactPhone: string | null; + notes: string | null; + active: boolean; + nutzer: number; + vertraege: number; + rechnungen: number; +}; + +const TYPEN: [string, string][] = [ + ["EIU", "Eisenbahninfrastrukturunternehmen (Bauherr)"], + ["AUFTRAGNEHMER", "Auftragnehmer / Baufirma"], + ["PLANUNGSBUERO", "Planungsbüro"], + ["GUTACHTER", "Gutachter, Prüfingenieur, Sachverständiger"], + ["SICHERUNGSUNTERNEHMEN", "Sicherungsunternehmen"], + ["BEHOERDE", "Behörde (EBA, Kommune, Land)"], + ["BETREIBER", "Anlagenbetreiber / Instandhaltung"], + ["SONSTIGE", "Sonstige"], +]; +const TYP_LABEL = Object.fromEntries(TYPEN); + +export function Firmenliste({ firmen }: { firmen: Firma[] }) { + const [neu, setNeu] = useState(false); + const [bearbeitet, setBearbeitet] = useState(null); + const [fehler, setFehler] = useState(null); + const [pending, start] = useTransition(); + + const lauf = (fn: () => Promise) => + start(async () => { + setFehler(null); + try { + await fn(); + } catch (e) { + setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich."); + } + }); + + return ( +
+ {fehler && ( +

{fehler}

+ )} + +
+

+ Der Firmenstamm bestimmt, wessen Daten ein Auftragnehmer sehen darf – die Firmenzuordnung + eines Zugangs ist die Grenze, an der die Sicht endet. Die Kreditorennummer erscheint in der + Rechnungsprüfung und erleichtert den Abgleich mit der Buchhaltung. +

+ +
+ + {neu && ( +
lauf(async () => { await firmaSpeichern(fd); setNeu(false); })} + > +
+

Neue Firma

+ +
+ + + + )} + +
+ + + + + + + + + + + + + + {firmen.map((f) => ( + + + + + + + + + + + + {bearbeitet === f.id && ( + + + + )} + + ))} + +
FirmaArtAnschriftKreditor / USt-IdNr.NutzerVerträgeAktion
+
+ {f.name} + {!f.active && } +
+
+ {f.shortName && `${f.shortName} · `} + {f.contactEmail ?? "keine Kontaktadresse"} + {f.contactPhone && ` · ${f.contactPhone}`} +
+
{TYP_LABEL[f.type] ?? f.type} + {f.street &&
{f.street}
} + {(f.zip || f.city) &&
{[f.zip, f.city].filter(Boolean).join(" ")}
} + {!f.street && !f.city && "—"} +
+ {f.kreditorNr ?? "—"} + {f.ustId &&
{f.ustId}
} +
{f.nutzer}{f.vertraege} +
+ + +
+
+
lauf(async () => { await firmaSpeichern(fd); setBearbeitet(null); })} + > + + +
+ + +
+ +
+
+ +

+ Firmen mit bestehenden Verträgen lassen sich nicht stilllegen. Stilllegen entfernt die Firma + aus den Auswahllisten, lässt aber alle Vorgänge unangetastet. +

+
+ ); +} + +function FirmaFelder({ werte }: { werte?: Firma }) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/app/src/app/(app)/verwaltung/firmen/page.tsx b/app/src/app/(app)/verwaltung/firmen/page.tsx new file mode 100644 index 0000000..c07465f --- /dev/null +++ b/app/src/app/(app)/verwaltung/firmen/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { Firmenliste } from "./Firmenliste"; + +export const dynamic = "force-dynamic"; + +export default async function FirmenVerwaltung() { + const user = await requireUser(); + if (!user.isAdmin) redirect("/uebersicht"); + + const firmen = await prisma.company.findMany({ + include: { _count: { select: { users: true, contracts: true, invoices: true } } }, + orderBy: [{ active: "desc" }, { type: "asc" }, { name: "asc" }], + }); + + return ( + ({ + id: f.id, + name: f.name, + shortName: f.shortName, + type: f.type, + street: f.street, + zip: f.zip, + city: f.city, + ustId: f.ustId, + kreditorNr: f.kreditorNr, + contactEmail: f.contactEmail, + contactPhone: f.contactPhone, + notes: f.notes, + active: f.active, + nutzer: f._count.users, + vertraege: f._count.contracts, + rechnungen: f._count.invoices, + }))} + /> + ); +} diff --git a/app/src/app/(app)/verwaltung/layout.tsx b/app/src/app/(app)/verwaltung/layout.tsx new file mode 100644 index 0000000..793099b --- /dev/null +++ b/app/src/app/(app)/verwaltung/layout.tsx @@ -0,0 +1,63 @@ +import { redirect } from "next/navigation"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { TabLink } from "@/components/NavLink"; +import { Kachel } from "@/components/ui"; + +export const dynamic = "force-dynamic"; + +export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { + const user = await requireUser(); + if (!user.isAdmin) redirect("/uebersicht"); + + const [benutzer, gesperrt, firmen, projekte, sollUnterlagen, ordner, speicher, sitzungen] = + await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { active: false } }), + prisma.company.count({ where: { active: true } }), + prisma.project.count(), + prisma.documentRequirement.count(), + prisma.filingNode.count(), + prisma.documentVersion.aggregate({ _sum: { groesse: true } }), + prisma.session.count({ where: { expiresAt: { gt: new Date() } } }), + ]); + + const mb = Number(speicher._sum.groesse ?? 0) / 1048576; + + return ( +
+
+

Verwaltung

+

+ Zugänge, Firmenstamm, Rechtemodell und Protokoll +

+
+ +
+ + + + +
+ + + + {children} +
+ ); +} diff --git a/app/src/app/(app)/verwaltung/page.tsx b/app/src/app/(app)/verwaltung/page.tsx index 9597d26..caf3986 100644 --- a/app/src/app/(app)/verwaltung/page.tsx +++ b/app/src/app/(app)/verwaltung/page.tsx @@ -1,147 +1,49 @@ -import { redirect } from "next/navigation"; import { requireUser } from "@/lib/auth"; import { prisma } from "@/lib/db"; -import { Kachel, StatusChip } from "@/components/ui"; import { ROLE_LABEL } from "@/lib/rbac"; -import { datumZeit } from "@/lib/format"; +import { Benutzerliste } from "./Benutzerliste"; export const dynamic = "force-dynamic"; -export default async function Verwaltung() { - const user = await requireUser(); - if (!user.isAdmin) redirect("/uebersicht"); +export default async function BenutzerVerwaltung() { + const admin = await requireUser(); - const [benutzer, firmen, protokoll, stats] = await Promise.all([ + const [benutzer, firmen] = await Promise.all([ prisma.user.findMany({ - include: { company: { select: { name: true } }, memberships: { select: { role: true } } }, + include: { + company: { select: { id: true, name: true, shortName: true } }, + memberships: { select: { role: true, project: { select: { nummer: true } } } }, + _count: { select: { sessions: true } }, + }, + orderBy: [{ active: "desc" }, { name: "asc" }], + }), + prisma.company.findMany({ + where: { active: true }, + select: { id: true, name: true, shortName: true, type: true }, orderBy: { name: "asc" }, }), - prisma.company.findMany({ include: { _count: { select: { users: true, contracts: true } } }, orderBy: { name: "asc" } }), - prisma.auditLog.findMany({ - include: { user: { select: { name: true } } }, - orderBy: { createdAt: "desc" }, - take: 60, - }), - Promise.all([ - prisma.project.count(), - prisma.document.count(), - prisma.documentRequirement.count(), - prisma.filingNode.count(), - prisma.documentVersion.aggregate({ _sum: { groesse: true } }), - ]), ]); - const [anzProjekte, anzDokumente, anzSoll, anzOrdner, speicher] = stats; - const mb = Number(speicher._sum.groesse ?? 0) / 1048576; - return ( -
-

Verwaltung

- -
- - - - -
- -
-

Benutzer

- - - - - - - - - - - - {benutzer.map((b) => ( - - - - - - - - ))} - -
NameE-MailFirmaRollenletzte Anmeldung
- {b.name} - {b.isAdmin && Admin} - {!b.active && gesperrt} -
{b.position}
-
{b.email}{b.company?.name ?? "—"} - {[...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))].join(", ") || "—"} - {datumZeit(b.lastLoginAt)}
-
- -
-

Firmen

- - - - - - - - - - - - - {firmen.map((f) => ( - - - - - - - - - ))} - -
FirmaArtOrtKreditor / USt-IdNr.NutzerVerträge
{f.name}{f.type.replace(/_/g, " ").toLowerCase()}{f.city ?? "—"} - {f.kreditorNr ?? "—"} - {f.ustId &&
{f.ustId}
} -
{f._count.users}{f._count.contracts}
-
- -
-

- Protokoll – revisionssichere Nachvollziehbarkeit -

- - - - - - - - - - - {protokoll.map((p) => ( - - - - - - - ))} - -
ZeitpunktPersonAktionObjekt
{datumZeit(p.createdAt)}{p.user?.name ?? "System"} - {p.aktion} - - {p.entitaet} - {p.entitaetId && · {p.entitaetId.slice(0, 10)}…} - {p.details ? ( -
{JSON.stringify(p.details).slice(0, 140)}
- ) : null} -
-
-
+ ({ + id: b.id, + email: b.email, + name: b.name, + position: b.position, + phone: b.phone, + active: b.active, + isAdmin: b.isAdmin, + mustChangePw: b.mustChangePw, + companyId: b.companyId, + firma: b.company?.shortName ?? b.company?.name ?? null, + lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null, + sitzungen: b._count.sessions, + rollen: [...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))], + projekte: b.memberships.length, + }))} + firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))} + /> ); } diff --git a/app/src/app/(app)/verwaltung/protokoll/page.tsx b/app/src/app/(app)/verwaltung/protokoll/page.tsx new file mode 100644 index 0000000..ebfe084 --- /dev/null +++ b/app/src/app/(app)/verwaltung/protokoll/page.tsx @@ -0,0 +1,169 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { datumZeit } from "@/lib/format"; +import { Leer } from "@/components/ui"; + +export const dynamic = "force-dynamic"; + +/** Aktionen, die aufbewahrungs- oder haftungsrelevant sind. */ +const GEWICHTIG = new Set([ + "SACHLICH_RICHTIG", "RECHNERISCH_RICHTIG", "ZAHLUNGSFREIGABE", "ZURUECKWEISUNG", + "FREIGABE", "ABSCHLUSS", "BEFUND_ERLEDIGT", "KENNWORT_ZURUECKGESETZT", + "BENUTZER_GESPERRT", "BENUTZER_ANGELEGT", "EINSTELLUNGEN", +]); + +export default async function Protokoll({ + searchParams, +}: { + searchParams: Promise<{ aktion?: string; q?: string; seite?: string }>; +}) { + const user = await requireUser(); + if (!user.isAdmin) redirect("/uebersicht"); + const sp = await searchParams; + + const seite = Math.max(1, Number(sp.seite ?? 1)); + const proSeite = 100; + + const filter = { + ...(sp.aktion ? { aktion: sp.aktion } : {}), + ...(sp.q + ? { + OR: [ + { entitaet: { contains: sp.q, mode: "insensitive" as const } }, + { entitaetId: { contains: sp.q } }, + { user: { name: { contains: sp.q, mode: "insensitive" as const } } }, + ], + } + : {}), + }; + + const [eintraege, gesamt, aktionen] = await Promise.all([ + prisma.auditLog.findMany({ + where: filter, + include: { user: { select: { name: true, email: true } } }, + orderBy: { createdAt: "desc" }, + skip: (seite - 1) * proSeite, + take: proSeite, + }), + prisma.auditLog.count({ where: filter }), + prisma.auditLog.groupBy({ by: ["aktion"], _count: true, orderBy: { _count: { aktion: "desc" } } }), + ]); + + const seiten = Math.ceil(gesamt / proSeite); + const link = (o: Record) => { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries({ aktion: sp.aktion, q: sp.q, ...o })) if (v) p.set(k, v); + return `/verwaltung/protokoll?${p.toString()}`; + }; + + return ( +
+

+ Jede Anmeldung, jeder Dateiabruf, jede Freigabe und jede Bescheinigung wird festgehalten. + Das Protokoll ist die Grundlage dafür, auch Jahre später zu belegen, wer wann was erklärt + hat – bei Aufbewahrungsfristen bis 38 Jahre nach Bauschlussmeldung der eigentliche Zweck + der Sache. Einträge lassen sich nicht bearbeiten oder löschen. +

+ +
+ + + + {(sp.q || sp.aktion) && ( + + zurücksetzen + + )} + + {gesamt.toLocaleString("de-DE")} Einträge + {seiten > 1 && ` · Seite ${seite} von ${seiten}`} + +
+ + {eintraege.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {eintraege.map((e) => ( + + + + + + + ))} + +
ZeitpunktPersonAktionObjekt und Einzelheiten
+ {datumZeit(e.createdAt)} + + {e.user?.name ?? System} + + + {e.aktion} + + + {e.entitaet} + {e.entitaetId && ( + {e.entitaetId.slice(0, 12)}… + )} + {e.details ? ( +
+ {JSON.stringify(e.details)} +
+ ) : null} +
+
+ )} + + {seiten > 1 && ( +
+ {seite > 1 && ( + + zurück + + )} + + Seite {seite} von {seiten} + + {seite < seiten && ( + + weiter + + )} +
+ )} +
+ ); +} diff --git a/app/src/app/(app)/verwaltung/rollen/page.tsx b/app/src/app/(app)/verwaltung/rollen/page.tsx new file mode 100644 index 0000000..a7c2d47 --- /dev/null +++ b/app/src/app/(app)/verwaltung/rollen/page.tsx @@ -0,0 +1,218 @@ +import { redirect } from "next/navigation"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { + PERMISSIONS, PERMISSION_INFO, PERMISSION_GRUPPEN, ROLE_LABEL, ROLE_HINT, + ROLE_KURZ, ROLE_SEITE, OPTION_INFO, can, type Permission, +} from "@/lib/rbac"; +import type { Role } from "@prisma/client"; + +export const dynamic = "force-dynamic"; + +const SEITE_TEXT = { + AG: { titel: "Auftraggeberseite", farbe: "border-db-cyan/50 bg-db-cyan/10 text-db-cyan" }, + AN: { titel: "Auftragnehmerseite", farbe: "border-amber-700 bg-amber-900/25 text-amber-300" }, + NEUTRAL: { titel: "Zuarbeit und Mitlesende", farbe: "border-db-line bg-db-dark text-slate-400" }, +} as const; + +export default async function Rollenlegende() { + const user = await requireUser(); + if (!user.isAdmin) redirect("/uebersicht"); + + const rollen = Object.keys(ROLE_LABEL) as Role[]; + const rechte = Object.keys(PERMISSIONS) as Permission[]; + + const belegung = await prisma.projectMember.groupBy({ by: ["role"], _count: true }); + const anzahl = new Map(belegung.map((b) => [b.role, b._count])); + + return ( +
+ {/* ── Grundprinzip ─────────────────────────────────────── */} +
+

Das Grundprinzip

+

+ Die Rollen bilden nicht ab, wer wem vorgesetzt ist, sondern auf welcher + Seite des Bauvertrags jemand steht. An dieser Linie hängen alle Rechte. Darüber liegt die + Vier-Augen-Pflicht aus dem Vorgabeprozess Bauüberwachung: sachlich richtig und{" "} + rechnerisch richtig können technisch nie von derselben Person bescheinigt werden. +

+

+ Rollen werden je Projekt vergeben – im Projekt unter{" "} + Einstellungen → Beteiligte, nicht hier. Dieselbe Person kann in einem Projekt + Bauüberwacher und in einem anderen nur Leser sein. +

+
+ + {/* ── Rollen ───────────────────────────────────────────── */} + {(["AG", "AN", "NEUTRAL"] as const).map((seite) => { + const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite); + if (gruppe.length === 0) return null; + return ( +
+
+ {SEITE_TEXT[seite].titel} + + {seite === "AG" && "vergibt, überwacht, prüft, gibt frei – sieht das gesamte Projekt"} + {seite === "AN" && "liefert, meldet, rechnet ab – sieht ausschließlich die eigene Firma"} + {seite === "NEUTRAL" && "arbeiten zu oder lesen mit"} + +
+ + + {gruppe.map((r) => ( + + + + + + + ))} + +
+ + {ROLE_KURZ[r]} + + {ROLE_LABEL[r]}{ROLE_HINT[r]} + {anzahl.get(r) ?? 0}× vergeben +
+
+ ); + })} + + {/* ── Matrix ───────────────────────────────────────────── */} +
+
+

Rechtematrix

+

+ Unmittelbar aus src/lib/rbac.ts erzeugt – diese + Tabelle kann nicht veralten. Ein bedeutet: für + Auftragnehmer zusätzlich vom Projektschalter abhängig. +

+
+ +
+ + + + + {rollen.map((r) => ( + + ))} + + + + {PERMISSION_GRUPPEN.map((gruppe) => { + const inGruppe = rechte.filter((p) => PERMISSION_INFO[p].gruppe === gruppe); + if (inGruppe.length === 0) return null; + return ( + + + + + {inGruppe.map((p) => { + const info = PERMISSION_INFO[p] as { label: string; optional?: string }; + return ( + + + {rollen.map((r) => { + const erlaubt = can([r], p); + const optional = !erlaubt && r === "AUFTRAGNEHMER" && !!info.optional; + return ( + + ); + })} + + ); + })} + + ); + })} + +
Recht + {ROLE_KURZ[r]} +
+ {gruppe} +
+ {info.label} + {info.optional && ( + + Schalter: {info.optional} + + )} + + {erlaubt ? ( + + ) : optional ? ( + + ) : ( + · + )} +
+
+
+ + {/* ── Projektschalter ──────────────────────────────────── */} +
+
+

Projektschalter für Auftragnehmer

+

+ Je Projekt einstellbar unter Projekt → Einstellungen. Die Schalter entscheiden + ausschließlich, ob ein Bereich sichtbar ist – niemals, wessen Daten darin + erscheinen. Ein Auftragnehmer sieht in jedem Fall nur die eigene Firma. +

+
+ + + + + + + + + + {OPTION_INFO.map((o) => ( + + + + + + ))} + + + + + + +
SchalterWirkungVoreinstellung
{o.titel}{o.hinweis} + + {o.standard ? "an" : "aus"} + +
Bescheinigung trotz kritischer Befunde + Erlaubt der Bauüberwachung, eine Rechnung auch mit offenen kritischen Prüfbefunden + sachlich zu bescheinigen – nur mit Begründung und protokolliert. Abgeschaltet müssen + zuerst alle Befunde als geklärt vermerkt sein. + + an +
+
+ + {/* ── Feste Sperren ────────────────────────────────────── */} +
+

Nicht einstellbar – und das mit Absicht

+
    +
  • · Sachlich und rechnerisch richtig kann nie dieselbe Person bescheinigen.
  • +
  • · Die Bauüberwachung bescheinigt, gibt aber keine Zahlung frei – das tun PL oder BHV.
  • +
  • · Der Fachbauüberwacher arbeitet dem Bauüberwacher zu und bescheinigt nicht selbst.
  • +
  • · Auftragnehmer sehen ausschließlich Daten der eigenen Firma.
  • +
  • · Wer nicht im Projekt eingetragen ist, sieht das Projekt nicht – auch nicht in der Liste.
  • +
  • · Es muss stets mindestens ein aktiver Administrationszugang bestehen.
  • +
+
+
+ ); +} + +/** Kleiner Helfer, damit Gruppenkopf und Zeilen einen gemeinsamen Schlüssel haben. */ +function Fragmentartig({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/app/src/app/anmelden/page.tsx b/app/src/app/anmelden/page.tsx index 9a28ed6..bd12a42 100644 --- a/app/src/app/anmelden/page.tsx +++ b/app/src/app/anmelden/page.tsx @@ -15,7 +15,8 @@ export default async function Anmelden({ const passwort = String(formData.get("passwort") ?? ""); const res = await login(email, passwort); if (!res.ok) redirect("/anmelden?fehler=1"); - redirect("/uebersicht"); + // Startkennwort der Administration: zuerst ein eigenes vergeben + redirect(res.kennwortWechseln ? "/profil?kennwort=erforderlich" : "/uebersicht"); } return ( diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts index 7d1fa85..042521f 100644 --- a/app/src/lib/auth.ts +++ b/app/src/lib/auth.ts @@ -14,6 +14,7 @@ export type SessionUser = { email: string; name: string; isAdmin: boolean; + mustChangePw: boolean; companyId: string | null; companyName: string | null; companyType: string | null; @@ -62,7 +63,7 @@ export async function login(email: string, password: string) { path: "/", expires: expiresAt, }); - return { ok: true as const }; + return { ok: true as const, kennwortWechseln: user.mustChangePw }; } export async function logout() { @@ -88,6 +89,7 @@ export async function getSessionUser(): Promise { email: session.user.email, name: session.user.name, isAdmin: session.user.isAdmin, + mustChangePw: session.user.mustChangePw, companyId: session.user.companyId, companyName: session.user.company?.name ?? null, companyType: session.user.company?.type ?? null, diff --git a/app/src/lib/rbac.ts b/app/src/lib/rbac.ts index d51a624..0c82219 100644 --- a/app/src/lib/rbac.ts +++ b/app/src/lib/rbac.ts @@ -87,6 +87,90 @@ export function assertCan(roles: Role[], permission: Permission) { } } +/** + * Klartext für die Rechtelegende. Bewusst als `satisfies Record`: + * Wer oben ein Recht ergänzt, bekommt hier einen Übersetzungsfehler und kann es + * nicht vergessen. + */ +export const PERMISSION_INFO = { + "projekt.anlegen": { gruppe: "Projekt", label: "Projekt anlegen" }, + "projekt.bearbeiten": { gruppe: "Projekt", label: "Projektdaten und Einstellungen ändern" }, + "projekt.mitglieder": { gruppe: "Projekt", label: "Beteiligte und Rollen vergeben" }, + + "dokument.lesen": { gruppe: "Dokumente", label: "Dokumente lesen" }, + "dokument.hochladen": { gruppe: "Dokumente", label: "Dokumente einstellen", optional: "anDarfDokumente" }, + "dokument.freigeben": { gruppe: "Dokumente", label: "Dokumente prüfen und freigeben" }, + "dokument.loeschen": { gruppe: "Dokumente", label: "Dokumente löschen" }, + "dokument.intern": { gruppe: "Dokumente", label: "Interne und vertrauliche Unterlagen sehen" }, + + "ablage.lesen": { gruppe: "Ablagestruktur", label: "Dokumentationsstand einsehen", optional: "anSichtDokumentation" }, + "ablage.steuern": { gruppe: "Ablagestruktur", label: "Status und Zuständigkeit setzen" }, + + "bautagebuch.lesen": { gruppe: "Bautagebuch", label: "Bautagebuch lesen", optional: "anSichtBautagebuch" }, + "bautagebuch.schreiben": { gruppe: "Bautagebuch", label: "Bautagebuch führen" }, + "bautagebuch.gegenzeichnen": { gruppe: "Bautagebuch", label: "Bautagebuch gegenzeichnen" }, + + "vorgang.anlegen": { gruppe: "Meldungen", label: "Meldung anlegen" }, + "vorgang.bearbeiten": { gruppe: "Meldungen", label: "Meldung bearbeiten" }, + "vorgang.schliessen": { gruppe: "Meldungen", label: "Meldung schließen" }, + + "vertrag.lesen": { gruppe: "Verträge", label: "Verträge einsehen", optional: "anSichtVertrag" }, + "vertrag.bearbeiten": { gruppe: "Verträge", label: "Verträge und LV pflegen" }, + "nachtrag.pruefen": { gruppe: "Verträge", label: "Nachtrag prüfen und bewerten" }, + + "stunden.einreichen": { gruppe: "Stunden und Aufmaß", label: "Stundenlohnzettel einreichen" }, + "stunden.pruefen": { gruppe: "Stunden und Aufmaß", label: "Stunden zeilenweise anerkennen" }, + "aufmass.einreichen": { gruppe: "Stunden und Aufmaß", label: "Aufmaß einreichen" }, + "aufmass.pruefen": { gruppe: "Stunden und Aufmaß", label: "Aufmaß anerkennen" }, + + "rechnung.erfassen": { gruppe: "Rechnungsprüfung", label: "Rechnung erfassen" }, + "rechnung.lesen": { gruppe: "Rechnungsprüfung", label: "Rechnungen einsehen", optional: "anSichtRechnungen" }, + "rechnung.sachlich": { gruppe: "Rechnungsprüfung", label: "sachlich richtig bescheinigen" }, + "rechnung.rechnerisch": { gruppe: "Rechnungsprüfung", label: "rechnerisch richtig bescheinigen" }, + "rechnung.freigeben": { gruppe: "Rechnungsprüfung", label: "Zahlung freigeben" }, + + "chat.schreiben": { gruppe: "Kommunikation", label: "Nachrichten schreiben" }, + "chat.kanal.anlegen": { gruppe: "Kommunikation", label: "Kanäle anlegen" }, + + "admin.benutzer": { gruppe: "Administration", label: "Benutzerverwaltung" }, + "admin.firmen": { gruppe: "Administration", label: "Firmenstamm pflegen" }, + "admin.audit": { gruppe: "Administration", label: "Protokoll einsehen" }, +} as const satisfies Record; + +export const PERMISSION_GRUPPEN = [ + "Projekt", "Dokumente", "Ablagestruktur", "Bautagebuch", "Meldungen", + "Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration", +] as const; + +export const ROLE_KURZ: Record = { + ADMIN: "ADM", + PROJEKTLEITER: "PL", + BAUHERRENVERTRETER: "BHV", + BAUUEBERWACHER: "BÜB", + FACHBAUUEBERWACHER: "FBÜ", + KAUFMANN: "KFM", + PLANER: "PLA", + AUFTRAGNEHMER: "AN", + PRUEFER: "PRF", + BETREIBER: "BTR", + LESER: "LES", +}; + +/** Auf welcher Seite des Bauvertrags eine Rolle steht. */ +export const ROLE_SEITE: Record = { + ADMIN: "NEUTRAL", + PROJEKTLEITER: "AG", + BAUHERRENVERTRETER: "AG", + BAUUEBERWACHER: "AG", + FACHBAUUEBERWACHER: "AG", + KAUFMANN: "AG", + PLANER: "NEUTRAL", + AUFTRAGNEHMER: "AN", + PRUEFER: "NEUTRAL", + BETREIBER: "NEUTRAL", + LESER: "NEUTRAL", +}; + export const ROLE_LABEL: Record = { ADMIN: "Administration", PROJEKTLEITER: "Projektleitung (PL)",