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}
+
+ navigator.clipboard?.writeText(zugang.kennwort)}
+ className="btn-ghost btn-sm"
+ >
+ kopieren
+
+ für {zugang.email}
+
+
+
setZugang(null)} className="text-emerald-300/60 hover:text-emerald-200">
+
+
+
+
+ )}
+
+ {fehler && (
+
{fehler}
+ )}
+
+ {/* ── Kopfzeile ────────────────────────────────────────── */}
+
+ setSuche(e.target.value)}
+ placeholder="Name, E-Mail, Firma oder Funktion …"
+ className="input w-80 py-1.5 text-xs"
+ />
+
+ setZeigeGesperrte(e.target.checked)} />
+ gesperrte Zugänge anzeigen
+
+ {gefiltert.length} von {benutzer.length}
+ { setNeu(!neu); setBearbeitet(null); }} className="btn-primary ml-auto">
+ Zugang anlegen
+
+
+
+ {/* ── Neuanlage ────────────────────────────────────────── */}
+ {neu && (
+
+ )}
+
+ {/* ── Liste ────────────────────────────────────────────── */}
+
+
+
+
+ Person
+ Firma
+ Projektrollen
+ Letzte Anmeldung
+ Aktion
+
+
+
+ {gefiltert.map((b) => (
+
+
+
+
+ {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)
}
+
+
+
+
{ setBearbeitet(bearbeitet === b.id ? null : b.id); setNeu(false); }}
+ className="btn-ghost btn-sm"
+ >
+ bearbeiten
+
+
{
+ if (!confirm(`Kennwort für ${b.name} zurücksetzen? Laufende Sitzungen werden beendet.`)) return;
+ const perMail = confirm("Zugangsdaten zusätzlich per E-Mail versenden?");
+ lauf(async () => {
+ const r = await kennwortZuruecksetzen(b.id, perMail);
+ setZugang(r);
+ });
+ }}
+ className="btn-ghost btn-sm"
+ title="Neues Startkennwort erzeugen"
+ >
+
+
+ {b.id !== eigeneId && (
+
{
+ if (b.active && !confirm(`Zugang von ${b.name} sperren? Laufende Sitzungen werden beendet.`)) return;
+ lauf(() => benutzerStatus(b.id, !b.active));
+ }}
+ className="btn-ghost btn-sm"
+ title={b.active ? "Zugang sperren" : "Zugang entsperren"}
+ >
+ {b.active ? : }
+
+ )}
+
+
+
+
+ {bearbeitet === b.id && (
+
+
+
+
+
+ )}
+
+ ))}
+
+
+
+
+
+ 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 (
+
+
+ Name
+
+
+
+ E-Mail (Anmeldename)
+
+
+
+ Telefon
+
+
+
+ Funktion
+
+
+
+ Firma
+
+ — ohne Zuordnung —
+ {firmen.map((f) => (
+
+ {f.name}
+
+ ))}
+
+
+
+
+
+
+ Administrationsrechte
+
+ Vollzugriff auf alle Projekte, Benutzerverwaltung und Protokoll – unabhängig von den
+ Projektrollen. Sparsam vergeben.
+ {eigenerZugang && " Am eigenen Zugang nicht abwählbar."}
+
+
+
+
+
+ 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.
+
+
{ setNeu(!neu); setBearbeitet(null); }} className="btn-primary shrink-0">
+ Firma anlegen
+
+
+
+ {neu && (
+
+ )}
+
+
+
+
+
+ Firma
+ Art
+ Anschrift
+ Kreditor / USt-IdNr.
+ Nutzer
+ Verträge
+ Aktion
+
+
+
+ {firmen.map((f) => (
+
+
+
+
+ {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}
+
+
+
{ setBearbeitet(bearbeitet === f.id ? null : f.id); setNeu(false); }}
+ className="btn-ghost btn-sm"
+ >
+ bearbeiten
+
+
lauf(() => firmaStatus(f.id, !f.active))}
+ className="btn-ghost btn-sm"
+ title={f.active ? "stilllegen" : "wieder aktivieren"}
+ >
+
+
+
+
+
+
+ {bearbeitet === f.id && (
+
+
+
+
+
+ )}
+
+ ))}
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+ Benutzer
+
+ Firmen
+ Rollen & Rechte
+ 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
-
-
-
- Name
- E-Mail
- Firma
- Rollen
- letzte Anmeldung
-
-
-
- {benutzer.map((b) => (
-
-
- {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
-
-
-
- Firma
- Art
- Ort
- Kreditor / USt-IdNr.
- Nutzer
- Verträge
-
-
-
- {firmen.map((f) => (
-
- {f.name}
- {f.type.replace(/_/g, " ").toLowerCase()}
- {f.city ?? "—"}
-
- {f.kreditorNr ?? "—"}
- {f.ustId && {f.ustId}
}
-
- {f._count.users}
- {f._count.contracts}
-
- ))}
-
-
-
-
-
-
- Protokoll – revisionssichere Nachvollziehbarkeit
-
-
-
-
- Zeitpunkt
- Person
- Aktion
- Objekt
-
-
-
- {protokoll.map((p) => (
-
- {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.
+
+
+
+
+ {eintraege.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Zeitpunkt
+ Person
+ Aktion
+ Objekt und Einzelheiten
+
+
+
+ {eintraege.map((e) => (
+
+
+ {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 ───────────────────────────────────────────── */}
+
+
+
+
+
+
+
+ Recht
+ {rollen.map((r) => (
+
+ {ROLE_KURZ[r]}
+
+ ))}
+
+
+
+ {PERMISSION_GRUPPEN.map((gruppe) => {
+ const inGruppe = rechte.filter((p) => PERMISSION_INFO[p].gruppe === gruppe);
+ if (inGruppe.length === 0) return null;
+ return (
+
+
+
+ {gruppe}
+
+
+ {inGruppe.map((p) => {
+ const info = PERMISSION_INFO[p] as { label: string; optional?: string };
+ return (
+
+
+ {info.label}
+ {info.optional && (
+
+ Schalter: {info.optional}
+
+ )}
+
+ {rollen.map((r) => {
+ const erlaubt = can([r], p);
+ const optional = !erlaubt && r === "AUFTRAGNEHMER" && !!info.optional;
+ return (
+
+ {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.
+
+
+
+
+
+ Schalter
+ Wirkung
+ Voreinstellung
+
+
+
+ {OPTION_INFO.map((o) => (
+
+ {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)",