Benutzerverwaltung, Firmenstamm und Rollenlegende

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) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-11 13:22:42 +02:00
co-authored by Claude Opus 5
parent 807b7ce541
commit 8af6b384ea
15 changed files with 1495 additions and 139 deletions
+35 -1
View File
@@ -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:
+2 -3
View File
@@ -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;
+13
View File
@@ -76,6 +76,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</div>
</header>
{user.mustChangePw && (
<div className="border-b border-amber-800/60 bg-amber-950/30">
<div className="mx-auto flex max-w-[1600px] items-center gap-3 px-5 py-2 text-sm">
<span className="text-amber-300">
Ihr Zugang läuft noch mit dem Startkennwort der Administration.
</span>
<Link href="/profil" className="font-medium text-amber-200 underline hover:text-amber-100">
Jetzt eigenes Kennwort vergeben
</Link>
</div>
</div>
)}
<main className="mx-auto max-w-[1600px] px-5 py-6">{children}</main>
</div>
);
+15 -1
View File
@@ -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<{
</p>
</div>
{(sp.kennwort === "erforderlich" || user.mustChangePw) && (
<div className="rounded-md border border-amber-800/60 bg-amber-950/20 px-4 py-3">
<p className="text-sm font-medium text-amber-300">Bitte vergeben Sie ein eigenes Kennwort.</p>
<p className="mt-0.5 text-xs leading-relaxed text-amber-200/70">
Ihr Zugang wurde mit einem Startkennwort der Administration eingerichtet. Dieses ist der
Administration bekannt gewesen und sollte nicht dauerhaft verwendet werden.
</p>
</div>
)}
{sp.fehler && (
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">
{sp.fehler === "altes-kennwort" ? "Das bisherige Kennwort ist nicht korrekt." : "Das neue Kennwort ist zu kurz (mindestens 10 Zeichen)."}
@@ -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<string | null>(null);
const [suche, setSuche] = useState("");
const [zeigeGesperrte, setZeigeGesperrte] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [zugang, setZugang] = useState<{ name: string; email: string; kennwort: string } | null>(null);
const [pending, start] = useTransition();
const lauf = (fn: () => Promise<unknown>) =>
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 (
<div className="space-y-4">
{/* ── Ausgabe eines neuen Kennworts ────────────────────── */}
{zugang && (
<div className="card border-emerald-800/60 bg-emerald-950/15 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-emerald-300">
Startkennwort für {zugang.name}
</h3>
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-emerald-200/70">
Es wird nur dieses eine Mal angezeigt und ist nirgends im Klartext gespeichert.
Beim ersten Anmelden muss ein eigenes Kennwort vergeben werden.
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<code className="select-all rounded border border-emerald-800/60 bg-db-dark px-3 py-2 font-mono text-base tracking-wide text-emerald-200">
{zugang.kennwort}
</code>
<button
onClick={() => navigator.clipboard?.writeText(zugang.kennwort)}
className="btn-ghost btn-sm"
>
<Copy size={13} /> kopieren
</button>
<span className="text-xs text-emerald-200/60">für {zugang.email}</span>
</div>
</div>
<button onClick={() => setZugang(null)} className="text-emerald-300/60 hover:text-emerald-200">
<X size={17} />
</button>
</div>
</div>
)}
{fehler && (
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">{fehler}</p>
)}
{/* ── Kopfzeile ────────────────────────────────────────── */}
<div className="card flex flex-wrap items-center gap-2 p-3">
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Name, E-Mail, Firma oder Funktion …"
className="input w-80 py-1.5 text-xs"
/>
<label className="flex items-center gap-1.5 text-xs text-slate-300">
<input type="checkbox" checked={zeigeGesperrte} onChange={(e) => setZeigeGesperrte(e.target.checked)} />
gesperrte Zugänge anzeigen
</label>
<span className="text-xs text-db-muted">{gefiltert.length} von {benutzer.length}</span>
<button onClick={() => { setNeu(!neu); setBearbeitet(null); }} className="btn-primary ml-auto">
<UserPlus size={15} /> Zugang anlegen
</button>
</div>
{/* ── Neuanlage ────────────────────────────────────────── */}
{neu && (
<form
className="card space-y-3 p-4"
action={(fd) =>
lauf(async () => {
const r = await benutzerAnlegen(fd);
setZugang({ name: r.name, email: r.email, kennwort: r.kennwort });
setNeu(false);
})
}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Neuen Zugang anlegen</h2>
<button type="button" onClick={() => setNeu(false)} className="text-db-muted hover:text-white">
<X size={17} />
</button>
</div>
<Felder firmen={firmen} />
<label className="flex items-center gap-2 text-xs text-slate-300">
<input type="checkbox" name="mailSenden" defaultChecked />
Zugangsdaten zusätzlich per E-Mail versenden
</label>
<button type="submit" disabled={pending} className="btn-primary">
<Plus size={14} /> {pending ? "wird angelegt …" : "Anlegen"}
</button>
</form>
)}
{/* ── Liste ────────────────────────────────────────────── */}
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Person</th>
<th className="th">Firma</th>
<th className="th">Projektrollen</th>
<th className="th w-40">Letzte Anmeldung</th>
<th className="th w-64 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{gefiltert.map((b) => (
<Fragment key={b.id}>
<tr className={`row ${!b.active ? "opacity-50" : ""}`}>
<td className="td">
<div className="flex flex-wrap items-center gap-2">
<span className="text-slate-100">{b.name}</span>
{b.isAdmin && <span className="chip border-db-red/50 bg-db-red/15 text-red-300">Administration</span>}
{!b.active && <StatusChip status="ENTFAELLT" text="gesperrt" />}
{b.mustChangePw && b.active && (
<span className="chip border-amber-700 bg-amber-900/25 text-amber-300">
Kennwortwechsel offen
</span>
)}
{b.id === eigeneId && <span className="text-[11px] text-db-muted">(Sie)</span>}
</div>
<div className="text-[11px] text-db-muted">
{b.email}
{b.position && ` · ${b.position}`}
{b.phone && ` · ${b.phone}`}
</div>
</td>
<td className="td text-xs text-slate-400">{b.firma ?? "—"}</td>
<td className="td text-xs text-slate-400">
{b.rollen.length ? b.rollen.join(", ") : <span className="text-db-muted">keine</span>}
{b.projekte > 0 && (
<div className="text-[10px] text-db-muted">
in {b.projekte} {b.projekte === 1 ? "Projekt" : "Projekten"}
</div>
)}
</td>
<td className="td text-xs tabular-nums text-db-muted">
{b.lastLoginAt ? datumZeit(b.lastLoginAt) : "noch nie"}
{b.sitzungen > 0 && <div className="text-[10px]">{b.sitzungen} Sitzung(en)</div>}
</td>
<td className="td">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
onClick={() => { setBearbeitet(bearbeitet === b.id ? null : b.id); setNeu(false); }}
className="btn-ghost btn-sm"
>
<Pencil size={12} /> bearbeiten
</button>
<button
disabled={pending}
onClick={() => {
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"
>
<KeyRound size={12} />
</button>
{b.id !== eigeneId && (
<button
disabled={pending}
onClick={() => {
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 ? <Lock size={12} /> : <Unlock size={12} className="text-emerald-400" />}
</button>
)}
</div>
</td>
</tr>
{bearbeitet === b.id && (
<tr className="border-t border-db-line bg-db-dark/40">
<td className="td" colSpan={5}>
<form
className="space-y-3 p-1"
action={(fd) =>
lauf(async () => {
await benutzerBearbeiten(fd);
setBearbeitet(null);
})
}
>
<input type="hidden" name="userId" value={b.id} />
<Felder firmen={firmen} werte={b} eigenerZugang={b.id === eigeneId} />
<div className="flex gap-2">
<button type="submit" disabled={pending} className="btn-primary btn-sm">
<Check size={13} /> Speichern
</button>
<button type="button" onClick={() => setBearbeitet(null)} className="btn-ghost btn-sm">
Abbrechen
</button>
</div>
</form>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
<p className="text-xs leading-relaxed text-db-muted">
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.
</p>
</div>
);
}
function Felder({
firmen,
werte,
eigenerZugang = false,
}: {
firmen: Firma[];
werte?: Benutzer;
eigenerZugang?: boolean;
}) {
return (
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="label mb-1.5 block">Name</label>
<input name="name" required defaultValue={werte?.name ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">E-Mail (Anmeldename)</label>
<input name="email" type="email" required defaultValue={werte?.email ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Telefon</label>
<input name="phone" defaultValue={werte?.phone ?? ""} className="input" />
</div>
<div className="sm:col-span-2">
<label className="label mb-1.5 block">Funktion</label>
<input
name="position"
defaultValue={werte?.position ?? ""}
placeholder="z. B. Bauüberwacher Bahn Oberbau/KIB"
className="input"
/>
</div>
<div>
<label className="label mb-1.5 block">Firma</label>
<select name="companyId" defaultValue={werte?.companyId ?? ""} className="input">
<option value=""> ohne Zuordnung </option>
{firmen.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
</div>
<div className="sm:col-span-3">
<label className={`flex items-start gap-2 text-sm ${eigenerZugang ? "opacity-60" : ""}`}>
<input type="checkbox" name="isAdmin" defaultChecked={werte?.isAdmin ?? false} className="mt-1" />
<span>
<span className="block text-slate-200">Administrationsrechte</span>
<span className="block text-[11px] leading-relaxed text-db-muted">
Vollzugriff auf alle Projekte, Benutzerverwaltung und Protokoll unabhängig von den
Projektrollen. Sparsam vergeben.
{eigenerZugang && " Am eigenen Zugang nicht abwählbar."}
</span>
</span>
</label>
</div>
<p className="sm:col-span-3 text-[11px] text-db-muted">
Projektrollen werden nicht hier, sondern im jeweiligen Projekt unter <em>Einstellungen
Beteiligte</em> vergeben. Das hält die Zuständigkeit dort, wo sie fachlich hingehört.
</p>
</div>
);
}
+236
View File
@@ -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");
}
@@ -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<string | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const [pending, start] = useTransition();
const lauf = (fn: () => Promise<unknown>) =>
start(async () => {
setFehler(null);
try {
await fn();
} catch (e) {
setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich.");
}
});
return (
<div className="space-y-4">
{fehler && (
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">{fehler}</p>
)}
<div className="flex items-center justify-between">
<p className="max-w-3xl text-xs leading-relaxed text-db-muted">
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.
</p>
<button onClick={() => { setNeu(!neu); setBearbeitet(null); }} className="btn-primary shrink-0">
<Building2 size={15} /> Firma anlegen
</button>
</div>
{neu && (
<form
className="card space-y-3 p-4"
action={(fd) => lauf(async () => { await firmaSpeichern(fd); setNeu(false); })}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Neue Firma</h2>
<button type="button" onClick={() => setNeu(false)} className="text-db-muted hover:text-white">
<X size={17} />
</button>
</div>
<FirmaFelder />
<button type="submit" disabled={pending} className="btn-primary">
<Plus size={14} /> {pending ? "wird angelegt …" : "Anlegen"}
</button>
</form>
)}
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Firma</th>
<th className="th">Art</th>
<th className="th">Anschrift</th>
<th className="th">Kreditor / USt-IdNr.</th>
<th className="th text-right">Nutzer</th>
<th className="th text-right">Verträge</th>
<th className="th w-44 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{firmen.map((f) => (
<Fragment key={f.id}>
<tr className={`row ${!f.active ? "opacity-50" : ""}`}>
<td className="td">
<div className="flex items-center gap-2">
<span className="text-slate-100">{f.name}</span>
{!f.active && <StatusChip status="ENTFAELLT" text="stillgelegt" />}
</div>
<div className="text-[11px] text-db-muted">
{f.shortName && `${f.shortName} · `}
{f.contactEmail ?? "keine Kontaktadresse"}
{f.contactPhone && ` · ${f.contactPhone}`}
</div>
</td>
<td className="td text-xs text-slate-400">{TYP_LABEL[f.type] ?? f.type}</td>
<td className="td text-xs text-slate-400">
{f.street && <div>{f.street}</div>}
{(f.zip || f.city) && <div>{[f.zip, f.city].filter(Boolean).join(" ")}</div>}
{!f.street && !f.city && "—"}
</td>
<td className="td text-xs text-slate-400">
{f.kreditorNr ?? "—"}
{f.ustId && <div>{f.ustId}</div>}
</td>
<td className="td text-right tabular-nums text-slate-300">{f.nutzer}</td>
<td className="td text-right tabular-nums text-slate-300">{f.vertraege}</td>
<td className="td">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => { setBearbeitet(bearbeitet === f.id ? null : f.id); setNeu(false); }}
className="btn-ghost btn-sm"
>
<Pencil size={12} /> bearbeiten
</button>
<button
disabled={pending}
onClick={() => lauf(() => firmaStatus(f.id, !f.active))}
className="btn-ghost btn-sm"
title={f.active ? "stilllegen" : "wieder aktivieren"}
>
<Power size={12} className={f.active ? "" : "text-emerald-400"} />
</button>
</div>
</td>
</tr>
{bearbeitet === f.id && (
<tr className="border-t border-db-line bg-db-dark/40">
<td className="td" colSpan={7}>
<form
className="space-y-3 p-1"
action={(fd) => lauf(async () => { await firmaSpeichern(fd); setBearbeitet(null); })}
>
<input type="hidden" name="companyId" value={f.id} />
<FirmaFelder werte={f} />
<div className="flex gap-2">
<button type="submit" disabled={pending} className="btn-primary btn-sm">
<Check size={13} /> Speichern
</button>
<button type="button" onClick={() => setBearbeitet(null)} className="btn-ghost btn-sm">
Abbrechen
</button>
</div>
</form>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
<p className="text-xs text-db-muted">
Firmen mit bestehenden Verträgen lassen sich nicht stilllegen. Stilllegen entfernt die Firma
aus den Auswahllisten, lässt aber alle Vorgänge unangetastet.
</p>
</div>
);
}
function FirmaFelder({ werte }: { werte?: Firma }) {
return (
<div className="grid gap-3 sm:grid-cols-4">
<div className="sm:col-span-2">
<label className="label mb-1.5 block">Firmenname</label>
<input name="name" required defaultValue={werte?.name ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kurzname</label>
<input name="shortName" defaultValue={werte?.shortName ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Art</label>
<select name="type" defaultValue={werte?.type ?? "AUFTRAGNEHMER"} className="input">
{TYPEN.map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<label className="label mb-1.5 block">Straße</label>
<input name="street" defaultValue={werte?.street ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">PLZ</label>
<input name="zip" defaultValue={werte?.zip ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Ort</label>
<input name="city" defaultValue={werte?.city ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kreditorennummer</label>
<input name="kreditorNr" defaultValue={werte?.kreditorNr ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">USt-IdNr.</label>
<input name="ustId" defaultValue={werte?.ustId ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kontakt E-Mail</label>
<input name="contactEmail" type="email" defaultValue={werte?.contactEmail ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kontakt Telefon</label>
<input name="contactPhone" defaultValue={werte?.contactPhone ?? ""} className="input" />
</div>
<div className="sm:col-span-4">
<label className="label mb-1.5 block">Bemerkung</label>
<input name="notes" defaultValue={werte?.notes ?? ""} className="input" />
</div>
</div>
);
}
@@ -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 (
<Firmenliste
firmen={firmen.map((f) => ({
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,
}))}
/>
);
}
+63
View File
@@ -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 (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-white">Verwaltung</h1>
<p className="mt-0.5 text-sm text-db-muted">
Zugänge, Firmenstamm, Rechtemodell und Protokoll
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kachel
titel="Zugänge"
wert={benutzer}
hinweis={`${gesperrt} gesperrt · ${sitzungen} aktive Sitzungen`}
/>
<Kachel titel="Firmen" wert={firmen} hinweis="aktiv im Stamm" />
<Kachel titel="Projekte" wert={projekte} />
<Kachel
titel="Ablagestruktur"
wert={sollUnterlagen}
hinweis={`${ordner} Ordner · ${mb.toFixed(1)} MB im Dateispeicher`}
/>
</div>
<nav className="flex gap-1 overflow-x-auto border-b border-db-line">
<TabLink href="/verwaltung" exact>
Benutzer
</TabLink>
<TabLink href="/verwaltung/firmen">Firmen</TabLink>
<TabLink href="/verwaltung/rollen">Rollen &amp; Rechte</TabLink>
<TabLink href="/verwaltung/protokoll">Protokoll</TabLink>
</nav>
{children}
</div>
);
}
+34 -132
View File
@@ -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 (
<div className="space-y-5">
<h1 className="text-xl font-semibold text-white">Verwaltung</h1>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kachel titel="Projekte" wert={anzProjekte} />
<Kachel titel="Benutzer" wert={benutzer.length} hinweis={`${firmen.length} Firmen`} />
<Kachel titel="Ablagestruktur" wert={anzSoll} hinweis={`${anzOrdner} Ordner, Katalog aus der EIU-Vorgabe`} />
<Kachel titel="Dokumente" wert={anzDokumente} hinweis={`${mb.toFixed(1)} MB im Dateispeicher`} />
</div>
<section className="card overflow-hidden">
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Benutzer</h2>
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Name</th>
<th className="th">E-Mail</th>
<th className="th">Firma</th>
<th className="th">Rollen</th>
<th className="th w-40 text-right">letzte Anmeldung</th>
</tr>
</thead>
<tbody>
{benutzer.map((b) => (
<tr key={b.id} className="row">
<td className="td">
<span className="text-slate-100">{b.name}</span>
{b.isAdmin && <span className="ml-2 chip border-db-red/50 bg-db-red/15 text-red-300">Admin</span>}
{!b.active && <span className="ml-2 chip border-slate-700 bg-slate-800 text-slate-500">gesperrt</span>}
<div className="text-[11px] text-db-muted">{b.position}</div>
</td>
<td className="td text-xs text-slate-400">{b.email}</td>
<td className="td text-xs text-slate-400">{b.company?.name ?? "—"}</td>
<td className="td text-xs text-slate-400">
{[...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))].join(", ") || "—"}
</td>
<td className="td text-right text-xs tabular-nums text-db-muted">{datumZeit(b.lastLoginAt)}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="card overflow-hidden">
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Firmen</h2>
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Firma</th>
<th className="th">Art</th>
<th className="th">Ort</th>
<th className="th">Kreditor / USt-IdNr.</th>
<th className="th text-right">Nutzer</th>
<th className="th text-right">Verträge</th>
</tr>
</thead>
<tbody>
{firmen.map((f) => (
<tr key={f.id} className="row">
<td className="td text-slate-100">{f.name}</td>
<td className="td text-xs text-slate-400">{f.type.replace(/_/g, " ").toLowerCase()}</td>
<td className="td text-xs text-slate-400">{f.city ?? "—"}</td>
<td className="td text-xs text-slate-400">
{f.kreditorNr ?? "—"}
{f.ustId && <div>{f.ustId}</div>}
</td>
<td className="td text-right tabular-nums text-slate-300">{f._count.users}</td>
<td className="td text-right tabular-nums text-slate-300">{f._count.contracts}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="card overflow-hidden">
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
Protokoll <span className="font-normal text-db-muted"> revisionssichere Nachvollziehbarkeit</span>
</h2>
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th w-40">Zeitpunkt</th>
<th className="th w-40">Person</th>
<th className="th w-44">Aktion</th>
<th className="th">Objekt</th>
</tr>
</thead>
<tbody>
{protokoll.map((p) => (
<tr key={p.id} className="row">
<td className="td whitespace-nowrap text-xs tabular-nums text-db-muted">{datumZeit(p.createdAt)}</td>
<td className="td text-xs text-slate-300">{p.user?.name ?? "System"}</td>
<td className="td">
<span className="chip border-db-line bg-db-dark text-slate-300">{p.aktion}</span>
</td>
<td className="td text-xs text-slate-400">
{p.entitaet}
{p.entitaetId && <span className="font-mono text-db-muted"> · {p.entitaetId.slice(0, 10)}</span>}
{p.details ? (
<div className="font-mono text-[10px] text-db-muted">{JSON.stringify(p.details).slice(0, 140)}</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
<Benutzerliste
eigeneId={admin.id}
benutzer={benutzer.map((b) => ({
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 }))}
/>
);
}
@@ -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<string, string | undefined>) => {
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 (
<div className="space-y-4">
<p className="max-w-3xl text-xs leading-relaxed text-db-muted">
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.
</p>
<form className="card flex flex-wrap items-center gap-2 p-3" action="/verwaltung/protokoll">
<input
name="q"
defaultValue={sp.q ?? ""}
placeholder="Person, Objektart oder Kennung …"
className="input w-72 py-1.5 text-xs"
/>
<select name="aktion" defaultValue={sp.aktion ?? ""} className="input w-64 py-1.5 text-xs">
<option value="">alle Aktionen</option>
{aktionen.map((a) => (
<option key={a.aktion} value={a.aktion}>
{a.aktion} ({a._count})
</option>
))}
</select>
<button className="btn-ghost btn-sm" type="submit">
Filtern
</button>
{(sp.q || sp.aktion) && (
<Link href="/verwaltung/protokoll" className="text-xs text-db-muted hover:text-slate-200">
zurücksetzen
</Link>
)}
<span className="ml-auto text-xs text-db-muted">
{gesamt.toLocaleString("de-DE")} Einträge
{seiten > 1 && ` · Seite ${seite} von ${seiten}`}
</span>
</form>
{eintraege.length === 0 ? (
<Leer text="Keine Protokolleinträge für diese Auswahl." />
) : (
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th w-40">Zeitpunkt</th>
<th className="th w-44">Person</th>
<th className="th w-52">Aktion</th>
<th className="th">Objekt und Einzelheiten</th>
</tr>
</thead>
<tbody>
{eintraege.map((e) => (
<tr key={e.id} className="row">
<td className="td whitespace-nowrap text-xs tabular-nums text-db-muted">
{datumZeit(e.createdAt)}
</td>
<td className="td text-xs text-slate-300">
{e.user?.name ?? <span className="text-db-muted">System</span>}
</td>
<td className="td">
<span
className={`chip ${
GEWICHTIG.has(e.aktion)
? "border-db-cyan/50 bg-db-cyan/10 text-db-cyan"
: "border-db-line bg-db-dark text-slate-400"
}`}
>
{e.aktion}
</span>
</td>
<td className="td text-xs text-slate-400">
{e.entitaet}
{e.entitaetId && (
<span className="ml-1 font-mono text-[10px] text-db-muted">{e.entitaetId.slice(0, 12)}</span>
)}
{e.details ? (
<div className="mt-0.5 break-all font-mono text-[10px] leading-relaxed text-db-muted">
{JSON.stringify(e.details)}
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{seiten > 1 && (
<div className="flex items-center justify-center gap-2">
{seite > 1 && (
<Link href={link({ seite: String(seite - 1) })} className="btn-ghost btn-sm">
zurück
</Link>
)}
<span className="text-xs text-db-muted">
Seite {seite} von {seiten}
</span>
{seite < seiten && (
<Link href={link({ seite: String(seite + 1) })} className="btn-ghost btn-sm">
weiter
</Link>
)}
</div>
)}
</div>
);
}
@@ -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 (
<div className="space-y-5">
{/* ── Grundprinzip ─────────────────────────────────────── */}
<section className="card-pad">
<h2 className="text-sm font-semibold text-white">Das Grundprinzip</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-slate-300">
Die Rollen bilden <strong>nicht ab, wer wem vorgesetzt ist</strong>, 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: <em>sachlich richtig</em> und{" "}
<em>rechnerisch richtig</em> können technisch nie von derselben Person bescheinigt werden.
</p>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">
Rollen werden <strong>je Projekt</strong> vergeben im Projekt unter{" "}
<em>Einstellungen Beteiligte</em>, nicht hier. Dieselbe Person kann in einem Projekt
Bauüberwacher und in einem anderen nur Leser sein.
</p>
</section>
{/* ── Rollen ───────────────────────────────────────────── */}
{(["AG", "AN", "NEUTRAL"] as const).map((seite) => {
const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite);
if (gruppe.length === 0) return null;
return (
<section key={seite} className="card overflow-hidden">
<header className="flex items-center gap-2 border-b border-db-line bg-db-dark/40 px-4 py-2.5">
<span className={`chip ${SEITE_TEXT[seite].farbe}`}>{SEITE_TEXT[seite].titel}</span>
<span className="text-xs text-db-muted">
{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"}
</span>
</header>
<table className="w-full">
<tbody>
{gruppe.map((r) => (
<tr key={r} className="row">
<td className="td w-16">
<span className="rounded bg-db-line px-1.5 py-0.5 font-mono text-[11px] text-slate-300">
{ROLE_KURZ[r]}
</span>
</td>
<td className="td w-64 text-slate-100">{ROLE_LABEL[r]}</td>
<td className="td text-sm leading-relaxed text-db-muted">{ROLE_HINT[r]}</td>
<td className="td w-32 whitespace-nowrap text-right text-xs tabular-nums text-db-muted">
{anzahl.get(r) ?? 0}× vergeben
</td>
</tr>
))}
</tbody>
</table>
</section>
);
})}
{/* ── Matrix ───────────────────────────────────────────── */}
<section className="card overflow-hidden">
<header className="border-b border-db-line px-4 py-3">
<h2 className="text-sm font-semibold text-white">Rechtematrix</h2>
<p className="mt-0.5 text-xs text-db-muted">
Unmittelbar aus <code className="text-slate-300">src/lib/rbac.ts</code> erzeugt diese
Tabelle kann nicht veralten. Ein <span className="text-amber-400"></span> bedeutet: für
Auftragnehmer zusätzlich vom Projektschalter abhängig.
</p>
</header>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th sticky left-0 bg-db-slate">Recht</th>
{rollen.map((r) => (
<th key={r} className="th text-center" title={ROLE_LABEL[r]}>
{ROLE_KURZ[r]}
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GRUPPEN.map((gruppe) => {
const inGruppe = rechte.filter((p) => PERMISSION_INFO[p].gruppe === gruppe);
if (inGruppe.length === 0) return null;
return (
<Fragmentartig key={gruppe}>
<tr className="border-t border-db-line/70 bg-db-dark/40">
<td className="td sticky left-0 bg-db-dark/40 text-[11px] font-semibold uppercase tracking-wider text-db-muted" colSpan={rollen.length + 1}>
{gruppe}
</td>
</tr>
{inGruppe.map((p) => {
const info = PERMISSION_INFO[p] as { label: string; optional?: string };
return (
<tr key={p} className="row">
<td className="td sticky left-0 bg-db-slate text-sm text-slate-200">
{info.label}
{info.optional && (
<span className="ml-1.5 text-[10px] text-amber-400/70">
Schalter: {info.optional}
</span>
)}
</td>
{rollen.map((r) => {
const erlaubt = can([r], p);
const optional = !erlaubt && r === "AUFTRAGNEHMER" && !!info.optional;
return (
<td key={r} className="td text-center">
{erlaubt ? (
<span className="text-emerald-400"></span>
) : optional ? (
<span className="text-amber-400" title="hängt am Projektschalter"></span>
) : (
<span className="text-slate-700">·</span>
)}
</td>
);
})}
</tr>
);
})}
</Fragmentartig>
);
})}
</tbody>
</table>
</div>
</section>
{/* ── Projektschalter ──────────────────────────────────── */}
<section className="card overflow-hidden">
<header className="border-b border-db-line px-4 py-3">
<h2 className="text-sm font-semibold text-white">Projektschalter für Auftragnehmer</h2>
<p className="mt-0.5 max-w-3xl text-xs leading-relaxed text-db-muted">
Je Projekt einstellbar unter <em>Projekt Einstellungen</em>. Die Schalter entscheiden
ausschließlich, <strong>ob ein Bereich sichtbar ist</strong> niemals, wessen Daten darin
erscheinen. Ein Auftragnehmer sieht in jedem Fall nur die eigene Firma.
</p>
</header>
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Schalter</th>
<th className="th">Wirkung</th>
<th className="th w-32 text-center">Voreinstellung</th>
</tr>
</thead>
<tbody>
{OPTION_INFO.map((o) => (
<tr key={o.feld} className="row">
<td className="td text-slate-100">{o.titel}</td>
<td className="td text-sm leading-relaxed text-db-muted">{o.hinweis}</td>
<td className="td text-center">
<span className={`chip ${o.standard ? "border-emerald-700 bg-emerald-900/25 text-emerald-300" : "border-slate-600 bg-slate-700/30 text-slate-400"}`}>
{o.standard ? "an" : "aus"}
</span>
</td>
</tr>
))}
<tr className="row">
<td className="td text-slate-100">Bescheinigung trotz kritischer Befunde</td>
<td className="td text-sm leading-relaxed text-db-muted">
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.
</td>
<td className="td text-center">
<span className="chip border-emerald-700 bg-emerald-900/25 text-emerald-300">an</span>
</td>
</tr>
</tbody>
</table>
</section>
{/* ── Feste Sperren ────────────────────────────────────── */}
<section className="card-pad">
<h2 className="text-sm font-semibold text-white">Nicht einstellbar und das mit Absicht</h2>
<ul className="mt-2 space-y-1.5 text-sm leading-relaxed text-slate-300">
<li>· Sachlich und rechnerisch richtig kann nie dieselbe Person bescheinigen.</li>
<li>· Die Bauüberwachung bescheinigt, gibt aber keine Zahlung frei das tun PL oder BHV.</li>
<li>· Der Fachbauüberwacher arbeitet dem Bauüberwacher zu und bescheinigt nicht selbst.</li>
<li>· Auftragnehmer sehen ausschließlich Daten der eigenen Firma.</li>
<li>· Wer nicht im Projekt eingetragen ist, sieht das Projekt nicht auch nicht in der Liste.</li>
<li>· Es muss stets mindestens ein aktiver Administrationszugang bestehen.</li>
</ul>
</section>
</div>
);
}
/** Kleiner Helfer, damit Gruppenkopf und Zeilen einen gemeinsamen Schlüssel haben. */
function Fragmentartig({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+2 -1
View File
@@ -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 (
+3 -1
View File
@@ -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<SessionUser | null> {
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,
+84
View File
@@ -87,6 +87,90 @@ export function assertCan(roles: Role[], permission: Permission) {
}
}
/**
* Klartext für die Rechtelegende. Bewusst als `satisfies Record<Permission, …>`:
* 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<Permission, { gruppe: string; label: string; optional?: keyof ProjektOptionen }>;
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<Role, string> = {
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<Role, "AG" | "AN" | "NEUTRAL"> = {
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<Role, string> = {
ADMIN: "Administration",
PROJEKTLEITER: "Projektleitung (PL)",