Rolleneditor: eigene Rollen neben den Standardrollen

Rechte sind jetzt die Grundgröße, eine Rolle ist ein benanntes Bündel davon.
Die elf Standardrollen stehen weiterhin im Code und sind unveränderlich –
Prüfvermerke und Bescheinigungen berufen sich auf sie. Daneben lassen sich
eigene Rollen anlegen.

Rolleneditor unter Verwaltung > Rollen & Rechte
- Rolle aus einer Standardrolle als Vorlage ableiten oder von Grund auf anlegen
- Rechte nach Modul gruppiert ankreuzen, mit Sammelschaltern je Gruppe
- Seite des Bauvertrags festlegen (Auftraggeber / Auftragnehmer / Zuarbeit) –
  bestimmt die Datensicht, unabhängig von der Rechteliste
- bearbeiten, stilllegen, löschen; die Rechtematrix zeigt eigene Rollen mit an

Grenzen
- Administrationsrechte sind nicht über eine Rolle vergebbar, sonst ließe sich
  über eine selbst gebaute Rolle die Benutzerverwaltung erreichen
- eine Rolle ohne jedes Recht wird abgelehnt
- eine vergebene Rolle lässt sich weder stilllegen noch löschen
- die Vier-Augen-Trennung bleibt unabhängig von der Rolle bestehen

Umbau der Rechteauswertung
- Rechte werden an genau einer Stelle ermittelt: rechteImProjekt() führt
  Standardrollen und eigene Rollen zu einer Rechtelage zusammen, die Seiten und
  Server Actions über rechte.darf() bzw. rechte.pruefe() befragen
- die Firmenschranke hängt nun an der Seite des Bauvertrags, die jede Rolle
  mitbringt, statt an einer festen Liste privilegierter Standardrollen
- ProjectMember trägt wahlweise eine Standardrolle oder eine eigene Rolle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-11 14:13:38 +02:00
co-authored by Claude Opus 5
parent 8af6b384ea
commit 84eb93c59f
39 changed files with 1078 additions and 252 deletions
+44 -4
View File
@@ -142,7 +142,12 @@ eingegrenzt.
### Vollständige Matrix
Quelle ist `app/src/lib/rbac.ts`; die Tabelle ist von dort erzeugt.
Quelle ist `app/src/lib/rbac.ts`; die Tabelle ist von dort erzeugt. In der Anwendung
zeigt *Verwaltung → Rollen & Rechte* dieselbe Matrix, dort um die eigenen Rollen ergänzt.
Ausgewertet werden Rechte an genau einer Stelle: `rechteImProjekt()` führt Standardrollen
und eigene Rollen zu einer `Rechtelage` zusammen, die jede Seite und jede Server Action
über `rechte.darf(...)` bzw. `rechte.pruefe(...)` befragt.
Spalte AN zeigt das Rollenrecht; ein `(○)` bedeutet: greift zusätzlich nur, wenn der
passende Projektschalter gesetzt ist (siehe Abschnitt *Je Projekt einstellbar*).
@@ -202,8 +207,9 @@ Bauherrn – einstellbar beim Anlegen und jederzeit unter *Projekt → Einstellu
**Diese Schalter steuern ausschließlich die Sichtbarkeit von Bereichen, niemals den
Datenumfang.** Ein Auftragnehmer sieht in jedem Fall nur Verträge, Aufmaße,
Stundenzettel und Rechnungen der eigenen Firma. Diese Schranke hängt allein an der
Rolle (`isExternalOnly()` in `src/lib/rbac.ts`) und ist durch keine Einstellung
aufhebbar; die Datenbankabfragen filtern zusätzlich auf `companyId`.
**Seite des Bauvertrags**, die jede Rolle mitbringt (`Rechtelage.nurEigeneFirma` in
`src/lib/rbac.ts`), und ist durch keine Einstellung aufhebbar; die Datenbankabfragen
filtern zusätzlich auf `companyId`.
Bewusst nicht geschaltet: Der Reiter **Beteiligte** zeigt allen Projektbeteiligten,
welche Firmen und Personen mitwirken. Das entspricht Position B.I 0 der
@@ -238,7 +244,7 @@ Unter *Verwaltung* (nur für Zugänge mit Administrationsrecht), in vier Reitern
|---|---|
| **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 |
| **Rollen & Rechte** | Rolleneditor für eigene Rollen, Legende aller Rollen und die vollständige Rechtematrix |
| **Protokoll** | Alle Vorgänge mit Filter nach Aktion und Person |
**Kennwörter.** Beim Anlegen wird ein Startkennwort erzeugt und **genau einmal** angezeigt
@@ -260,6 +266,40 @@ Firmen mit bestehenden Verträgen lassen sich nicht stilllegen.
*Einstellungen → Beteiligte*. Das Administrationsrecht ist davon unabhängig und wirkt
projektübergreifend – entsprechend sparsam vergeben.
### Eigene Rollen
Die **elf Standardrollen sind Teil des Programms** – sie bilden den Vorgabeprozess ab und
lassen sich weder ändern noch löschen. Prüfvermerke und Bescheinigungen berufen sich auf
sie; ein nachträglich verändertes Rollenverständnis würde die Nachvollziehbarkeit alter
Vorgänge zerstören.
Zusätzliche Rollen entstehen unter *Verwaltung → Rollen & Rechte → Rolle anlegen*:
1. **Vorlage wählen** (optional) – übernimmt Rechte und Seite einer Standardrolle als
Ausgangspunkt. Ohne Vorlage beginnt man bei null.
2. **Seite des Bauvertrags festlegen** – Auftraggeberseite (sieht das ganze Projekt) oder
Auftragnehmerseite bzw. Zuarbeit (sieht ausschließlich die eigene Firma). Das ist die
folgenreichste Angabe: Sie bestimmt die Datensicht, nicht die Rechteliste.
3. **Rechte ankreuzen** – gruppiert nach Modul, mit Sammelschaltern je Gruppe.
Typischer Anlass: eine Bauüberwachung, die das Bautagebuch führt und Stunden anerkennt,
aber die sachliche Richtigkeit nicht bescheinigen soll. Vorlage *Bauüberwacher Bahn*
wählen, das Recht *sachlich richtig bescheinigen* abwählen, fertig.
Eigene Rollen werden im Projekt unter *Einstellungen → Beteiligte* genauso vergeben wie
Standardrollen und erscheinen in allen Rechteübersichten mit dem Vermerk „eigene Rolle".
**Grenzen, die auch der Rolleneditor nicht überschreitet:**
- **Administrationsrechte** (Benutzerverwaltung, Firmenstamm, Protokoll) lassen sich nicht
über eine Rolle vergeben – sonst käme man über eine selbst gebaute Rolle an die
Benutzerverwaltung und damit an jede andere Schranke. Sie werden ausschließlich am Zugang
gesetzt.
- Eine Rolle **ohne jedes Recht** wird abgelehnt.
- Eine Rolle, die **noch vergeben ist**, lässt sich weder stilllegen noch löschen.
- Die Vier-Augen-Trennung bleibt: Wer *sachlich richtig* bescheinigt hat, kann dieselbe
Rechnung nicht *rechnerisch richtig* bescheinigen – unabhängig von der Rolle.
### Absichtliche Sperren
Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache:
+38 -2
View File
@@ -40,6 +40,34 @@ enum Role {
LESER
}
/// Auf welcher Seite des Bauvertrags eine Rolle steht. Bestimmt, ob die
/// Trägerin oder der Träger das ganze Projekt oder nur die eigene Firma sieht.
enum RolleSeite {
AG // Bauherrenseite – sieht das gesamte Projekt
AN // Auftragnehmerseite – sieht ausschließlich die eigene Firma
NEUTRAL // Zuarbeit, Prüfung, Mitlesen – ebenfalls auf die eigene Firma beschränkt
}
/// Frei definierbare Rolle. Die elf Standardrollen stehen im Code
/// (src/lib/rbac.ts) und sind unveränderlich; hier entstehen zusätzliche
/// Rollen, wahlweise auf Grundlage einer Standardrolle als Vorlage.
model CustomRole {
id String @id @default(cuid())
schluessel String @unique // technischer Name, z.B. "fbue-oberleitung"
name String
beschreibung String?
seite RolleSeite @default(NEUTRAL)
vorlage Role? // Standardrolle, von der abgeleitet wurde
rechte String[] @default([]) // Schlüssel aus PERMISSIONS
aktiv Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
createdBy User? @relation("CustomRoleAutor", fields: [createdById], references: [id])
mitglieder ProjectMember[]
}
model Company {
id String @id @default(cuid())
name String
@@ -111,6 +139,7 @@ model User {
auditLogs AuditLog[]
slotResponsible ProjectDocumentSlot[] @relation("SlotResponsible")
tasks Task[] @relation("TaskAssignee")
eigeneRollen CustomRole[] @relation("CustomRoleAutor")
@@index([companyId])
}
@@ -140,13 +169,20 @@ model ProjectMember {
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role
// Entweder eine Standardrolle aus dem Code oder eine selbst angelegte.
// Genau eines von beiden ist gesetzt; darauf achtet die Anwendungsschicht.
role Role?
customRoleId String?
customRole CustomRole? @relation(fields: [customRoleId], references: [id])
// Fachbereiche, auf die sich diese Rolle beschränkt (leer = alle)
fachbereiche String[] @default([])
createdAt DateTime @default(now())
@@unique([projectId, userId, role])
@@unique([projectId, userId, role, customRoleId])
@@index([userId])
@@index([customRoleId])
}
// ═══════════════════════════════════════════════════════════════════
+3 -3
View File
@@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache";
import { requireUser, hashPassword, verifyPassword } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ROLE_LABEL } from "@/lib/rbac";
import { rollenBezeichnung } from "@/lib/rbac";
import { datumZeit } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -18,7 +18,7 @@ export default async function Profil({
where: { id: session.id },
include: {
company: true,
memberships: { include: { project: { select: { nummer: true, name: true } } } },
memberships: { include: { project: { select: { nummer: true, name: true } }, customRole: { select: { name: true } } } },
},
});
@@ -149,7 +149,7 @@ export default async function Profil({
<div className="text-slate-200">{m.project.name}</div>
<div className="text-[11px] text-db-muted">{m.project.nummer}</div>
</td>
<td className="td text-right text-sm text-slate-300">{ROLE_LABEL[m.role]}</td>
<td className="td text-right text-sm text-slate-300">{rollenBezeichnung(m)}</td>
</tr>
))}
</tbody>
@@ -2,15 +2,14 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { protokolliere } from "@/lib/notify";
export async function eintragAnlegen(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "bautagebuch.schreiben");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("bautagebuch.schreiben");
const datum = new Date(String(formData.get("datum")));
const letzter = await prisma.siteDiaryEntry.findFirst({
@@ -52,8 +51,8 @@ export async function eintragAnlegen(formData: FormData) {
export async function eintragAbschliessen(eintragId: string) {
const user = await requireUser();
const eintrag = await prisma.siteDiaryEntry.findUniqueOrThrow({ where: { id: eintragId } });
const roles = await rolesInProject(user.id, eintrag.projectId);
assertCan(roles, "bautagebuch.schreiben");
const rechte = await rechteImProjekt(user.id, eintrag.projectId);
rechte.pruefe("bautagebuch.schreiben");
if (eintrag.gesperrtAm) throw new Error("Der Eintrag ist bereits gesperrt.");
await prisma.siteDiaryEntry.update({
@@ -1,7 +1,6 @@
import { CloudSun, Lock, PenLine, Users } from "lucide-react";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Leer, Feld, KeinZugriff } from "@/components/ui";
import { datum, zahl } from "@/lib/format";
import { NeuerEintrag } from "./NeuerEintrag";
@@ -11,11 +10,11 @@ export const dynamic = "force-dynamic";
export default async function Bautagebuch({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { user, roles, optionen } = await projektKontext(id);
if (!canInProject(roles, "bautagebuch.lesen", optionen))
const { user, rechte } = await projektKontext(id);
if (!rechte.darf("bautagebuch.lesen"))
return <KeinZugriff bereich="Das Bautagebuch" />;
const darfSchreiben = can(roles, "bautagebuch.schreiben");
const darfSchreiben = rechte.darf("bautagebuch.schreiben");
const eintraege = await prisma.siteDiaryEntry.findMany({
where: { projectId: id },
@@ -1,6 +1,6 @@
import { requireProjectAccess } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ROLE_LABEL, ROLE_HINT, PERMISSIONS, can } from "@/lib/rbac";
import { ROLE_LABEL, ROLE_HINT, PERMISSIONS, PERMISSION_INFO, can, rollenBezeichnung, rechtelage, OPTIONEN_OFFEN } from "@/lib/rbac";
import { datum } from "@/lib/format";
import type { Role } from "@prisma/client";
@@ -22,7 +22,7 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
const mitglieder = await prisma.projectMember.findMany({
where: { projectId: id },
include: { user: { include: { company: true } } },
include: { user: { include: { company: true } }, customRole: true },
orderBy: [{ role: "asc" }],
});
@@ -33,7 +33,8 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
nachFirma.get(k)!.push(m);
}
const rollenImProjekt = [...new Set(mitglieder.map((m) => m.role))] as Role[];
const rollenImProjekt = [...new Set(mitglieder.map((m) => m.role).filter((r): r is Role => !!r))];
const eigeneImProjekt = [...new Map(mitglieder.filter((m) => m.customRole).map((m) => [m.customRole!.id, m.customRole!])).values()];
return (
<div className="space-y-5">
@@ -70,8 +71,15 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
<div className="text-[11px] text-db-muted">{m.user.position}</div>
</td>
<td className="td">
<span className="chip border-db-line bg-db-dark text-slate-300">{ROLE_LABEL[m.role]}</span>
<div className="mt-0.5 text-[11px] text-db-muted">{ROLE_HINT[m.role]}</div>
<span className="chip border-db-line bg-db-dark text-slate-300">{rollenBezeichnung(m)}</span>
{m.customRole && (
<span className="ml-1.5 chip border-db-cyan/40 bg-db-cyan/10 text-[10px] text-db-cyan">
eigene Rolle
</span>
)}
<div className="mt-0.5 text-[11px] text-db-muted">
{m.role ? ROLE_HINT[m.role] : (m.customRole?.beschreibung ?? "Selbst angelegte Rolle.")}
</div>
</td>
<td className="td text-xs text-slate-400">
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
@@ -119,6 +127,23 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
))}
</tr>
))}
{eigeneImProjekt.map((e) => (
<tr key={e.id} className="row">
<td className="td text-slate-200">
{e.name}
<span className="ml-1.5 chip border-db-cyan/40 bg-db-cyan/10 text-[10px] text-db-cyan">eigen</span>
</td>
{RECHTE_ANZEIGE.map((r) => (
<td key={r.schluessel} className="td text-center">
{e.rechte.includes(r.schluessel) ? (
<span className="text-emerald-400">✓</span>
) : (
<span className="text-slate-700">—</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { protokolliere, benachrichtige } from "@/lib/notify";
import type { SlotStatus } from "@prisma/client";
@@ -17,8 +16,8 @@ export async function slotAendern(formData: FormData) {
where: { id: slotId },
include: { requirement: { select: { titel: true } } },
});
const roles = await rolesInProject(user.id, slot.projectId);
assertCan(roles, "ablage.steuern");
const rechte = await rechteImProjekt(user.id, slot.projectId);
rechte.pruefe("ablage.steuern");
const daten: Record<string, unknown> = {};
if (status) {
@@ -1,7 +1,6 @@
import Link from "next/link";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Fortschritt, AmpelPunkt, Kachel, KeinZugriff } from "@/components/ui";
import { datum, tageBis } from "@/lib/format";
import { fristAmpel, UEBERGABE_LABEL, berechneAufbewahrung } from "@/lib/fristen";
@@ -25,11 +24,11 @@ export default async function Dokumentation({
}) {
const { id } = await params;
const sp = await searchParams;
const { roles, projekt, optionen } = await projektKontext(id);
if (!canInProject(roles, "ablage.lesen", optionen))
const { rechte, projekt } = await projektKontext(id);
if (!rechte.darf("ablage.lesen"))
return <KeinZugriff bereich="Der Dokumentationsstand" />;
const darfSteuern = can(roles, "ablage.steuern");
const darfSteuern = rechte.darf("ablage.steuern");
const akte = sp.akte ?? "P";
const alle = await prisma.projectDocumentSlot.findMany({
@@ -2,9 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan, assertCanInProject, can } from "@/lib/rbac";
import { optionenAus } from "@/lib/projekte";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { speichereDatei, maxUploadBytes, mimeErlaubt } from "@/lib/storage";
import { protokolliere, benachrichtige } from "@/lib/notify";
import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
@@ -12,10 +10,10 @@ import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
export async function dokumentHochladen(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
const rechte = await rechteImProjekt(user.id, projectId);
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
assertCanInProject(roles, "dokument.hochladen", optionenAus(projekt));
rechte.pruefe("dokument.hochladen");
const datei = formData.get("datei") as File | null;
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
if (datei.size > maxUploadBytes())
@@ -107,9 +105,9 @@ export async function neueVersion(formData: FormData) {
where: { id: documentId },
include: { project: { select: { nummer: true } }, versions: { orderBy: { version: "desc" }, take: 1 } },
});
const roles = await rolesInProject(user.id, dokument.projectId);
const rechte = await rechteImProjekt(user.id, dokument.projectId);
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: dokument.projectId } });
assertCanInProject(roles, "dokument.hochladen", optionenAus(projekt));
rechte.pruefe("dokument.hochladen");
const datei = formData.get("datei") as File | null;
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
@@ -147,8 +145,8 @@ export async function dokumentStatus(formData: FormData) {
where: { id: documentId },
select: { projectId: true, titel: true, slotId: true, createdById: true },
});
const roles = await rolesInProject(user.id, dokument.projectId);
assertCan(roles, "dokument.freigeben");
const rechte = await rechteImProjekt(user.id, dokument.projectId);
rechte.pruefe("dokument.freigeben");
await prisma.document.update({ where: { id: documentId }, data: { status } });
await prisma.documentReview.create({
@@ -189,7 +187,3 @@ export async function dokumentStatus(formData: FormData) {
revalidatePath(`/projekte/${dokument.projectId}/dokumente`);
revalidatePath(`/projekte/${dokument.projectId}/dokumentation`);
}
export async function darfFreigeben(projectId: string, userId: string) {
return can(await rolesInProject(userId, projectId), "dokument.freigeben");
}
@@ -2,7 +2,6 @@ import Link from "next/link";
import { Download, FileText, Upload, ShieldAlert } from "lucide-react";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Leer } from "@/components/ui";
import { datum, datumZeit } from "@/lib/format";
import { UploadPanel } from "./UploadPanel";
@@ -21,10 +20,10 @@ export default async function Dokumente({
}) {
const { id } = await params;
const sp = await searchParams;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
const darfHochladen = canInProject(roles, "dokument.hochladen", optionen);
const darfFreigeben = can(roles, "dokument.freigeben");
const darfHochladen = rechte.darf("dokument.hochladen");
const darfFreigeben = rechte.darf("dokument.freigeben");
const nurExtern = nurEigeneFirma;
const dokumente = await prisma.document.findMany({
@@ -12,6 +12,7 @@ type Mitglied = {
firma: string | null;
role: string;
roleLabel: string;
eigen: boolean;
fachbereiche: string[];
};
@@ -25,7 +26,7 @@ export function Mitglieder({
projectId: string;
mitglieder: Mitglied[];
personen: { id: string; name: string; position: string | null; firma: string | null }[];
rollen: { wert: string; label: string; hinweis: string }[];
rollen: { wert: string; label: string; hinweis: string; eigen: boolean; seite: string }[];
fachbereicheAuswahl: string[];
}) {
const [offen, setOffen] = useState(false);
@@ -87,6 +88,7 @@ export function Mitglieder({
{rollen.map((r) => (
<option key={r.wert} value={r.wert}>
{r.label}
{r.eigen ? " (eigene Rolle)" : ""}
</option>
))}
</select>
@@ -145,6 +147,9 @@ export function Mitglieder({
</td>
<td className="td">
<span className="chip border-db-line bg-db-dark text-slate-300">{m.roleLabel}</span>
{m.eigen && (
<span className="ml-1.5 chip border-db-cyan/40 bg-db-cyan/10 text-[10px] text-db-cyan">eigen</span>
)}
</td>
<td className="td text-xs text-slate-400">
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { slotsErzeugen, fristenNeuBerechnen, meilensteineAusTerminen } from "@/lib/projekte";
import { benachrichtige, protokolliere } from "@/lib/notify";
import type { ProjectStatus, Role } from "@prisma/client";
@@ -17,8 +16,8 @@ function datumOderNull(v: FormDataEntryValue | null): Date | null {
export async function einstellungenSpeichern(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "projekt.bearbeiten");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("projekt.bearbeiten");
const vorher = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
const neu = {
@@ -48,8 +47,8 @@ export async function einstellungenSpeichern(formData: FormData) {
export async function projektdatenSpeichern(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "projekt.bearbeiten");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("projekt.bearbeiten");
const termine = {
startgespraech: datumOderNull(formData.get("startgespraech")),
@@ -97,19 +96,28 @@ export async function projektdatenSpeichern(formData: FormData) {
export async function mitgliedHinzufuegen(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "projekt.mitglieder");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("projekt.mitglieder");
const userId = String(formData.get("userId"));
const role = String(formData.get("role")) as Role;
const auswahl = String(formData.get("role"));
const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
if (!userId) throw new Error("Bitte eine Person auswählen.");
if (!auswahl) throw new Error("Bitte eine Rolle auswählen.");
await prisma.projectMember.upsert({
where: { projectId_userId_role: { projectId, userId, role } },
create: { projectId, userId, role, fachbereiche },
update: { fachbereiche },
// Eigene Rollen kommen als "custom:<id>" aus dem Formular.
const eigen = auswahl.startsWith("custom:");
const role = eigen ? null : (auswahl as Role);
const customRoleId = eigen ? auswahl.slice(7) : null;
const vorhanden = await prisma.projectMember.findFirst({
where: { projectId, userId, role, customRoleId },
});
if (vorhanden) {
await prisma.projectMember.update({ where: { id: vorhanden.id }, data: { fachbereiche } });
} else {
await prisma.projectMember.create({ data: { projectId, userId, role, customRoleId, fachbereiche } });
}
// In den allgemeinen Projektkanal aufnehmen
const kanal = await prisma.channel.findFirst({ where: { projectId, kind: "PROJEKT" } });
@@ -124,7 +132,7 @@ export async function mitgliedHinzufuegen(formData: FormData) {
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
await protokolliere({
userId: user.id, aktion: "MITGLIED_HINZU", entitaet: "ProjectMember",
entitaetId: userId, projectId, details: { role },
entitaetId: userId, projectId, details: { role: auswahl },
});
await benachrichtige({
userIds: [userId],
@@ -145,8 +153,8 @@ export async function mitgliedEntfernen(formData: FormData) {
const memberId = String(formData.get("memberId"));
const mitglied = await prisma.projectMember.findUniqueOrThrow({ where: { id: memberId } });
const roles = await rolesInProject(user.id, mitglied.projectId);
assertCan(roles, "projekt.mitglieder");
const rechte = await rechteImProjekt(user.id, mitglied.projectId);
rechte.pruefe("projekt.mitglieder");
// Die letzte Projektleitung darf nicht entfernt werden
if (mitglied.role === "PROJEKTLEITER") {
@@ -1,25 +1,28 @@
import { redirect } from "next/navigation";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can, ROLE_LABEL, ROLE_HINT, OPTION_INFO } from "@/lib/rbac";
import { OPTION_INFO, rollenBezeichnung } from "@/lib/rbac";
import { rollenAuswahl } from "@/lib/rollenauswahl";
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
import { KeinZugriff } from "@/components/ui";
import { EinstellungenFormular } from "./EinstellungenFormular";
import { ProjektdatenFormular } from "./ProjektdatenFormular";
import { Mitglieder } from "./Mitglieder";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic";
export default async function Einstellungen({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { roles, projekt } = await projektKontext(id);
if (!can(roles, "projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />;
const { rechte, projekt } = await projektKontext(id);
if (!rechte.darf("projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />;
const [mitglieder, personen, slotStat] = await Promise.all([
prisma.projectMember.findMany({
where: { projectId: id },
include: { user: { select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true } } } } },
include: {
user: { select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true } } } },
customRole: { select: { id: true, name: true } },
},
orderBy: [{ role: "asc" }],
}),
prisma.user.findMany({
@@ -30,6 +33,8 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
prisma.projectDocumentSlot.count({ where: { projectId: id } }),
]);
const rollen = await rollenAuswahl();
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
return (
@@ -66,7 +71,7 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
}}
/>
{can(roles, "projekt.mitglieder") && (
{rechte.darf("projekt.mitglieder") && (
<Mitglieder
projectId={id}
mitglieder={mitglieder.map((m) => ({
@@ -75,8 +80,9 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
name: m.user.name,
position: m.user.position,
firma: m.user.company?.shortName ?? m.user.company?.name ?? null,
role: m.role,
roleLabel: ROLE_LABEL[m.role],
role: m.customRole ? `custom:${m.customRole.id}` : (m.role ?? ""),
roleLabel: rollenBezeichnung(m),
eigen: !!m.customRole,
fachbereiche: m.fachbereiche,
}))}
personen={personen.map((p) => ({
@@ -85,11 +91,7 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
position: p.position,
firma: p.company?.shortName ?? p.company?.name ?? null,
}))}
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({
wert: r,
label: ROLE_LABEL[r],
hinweis: ROLE_HINT[r],
}))}
rollen={rollen}
fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ}
/>
)}
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { benachrichtige, findeErwaehnungen } from "@/lib/notify";
export async function nachrichtSenden(formData: FormData) {
@@ -18,8 +17,8 @@ export async function nachrichtSenden(formData: FormData) {
});
if (!kanal.projectId) throw new Error("Kanal ohne Projektbezug.");
const roles = await rolesInProject(user.id, kanal.projectId);
assertCan(roles, "chat.schreiben");
const rechte = await rechteImProjekt(user.id, kanal.projectId);
rechte.pruefe("chat.schreiben");
if (!kanal.mitglieder.some((m) => m.userId === user.id) && !user.isAdmin)
throw new Error("Sie sind kein Mitglied dieses Kanals.");
@@ -74,8 +73,8 @@ export async function nachrichtSenden(formData: FormData) {
export async function kanalAnlegen(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "chat.kanal.anlegen");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("chat.kanal.anlegen");
const privat = formData.get("privat") === "on";
const mitgliedIds = formData.getAll("mitglieder").map(String);
@@ -2,7 +2,6 @@ import Link from "next/link";
import { Hash, Lock, Users } from "lucide-react";
import { requireProjectAccess } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can } from "@/lib/rbac";
import { relativ } from "@/lib/format";
import { Nachrichtenfeld } from "./Nachrichtenfeld";
import { NeuerKanal } from "./NeuerKanal";
@@ -19,7 +18,7 @@ export default async function Kommunikation({
}) {
const { id } = await params;
const sp = await searchParams;
const { user, roles } = await requireProjectAccess(id);
const { user, rechte } = await requireProjectAccess(id);
const kanaele = await prisma.channel.findMany({
where: {
@@ -56,7 +55,7 @@ export default async function Kommunikation({
return (
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<aside className="space-y-3">
{can(roles, "chat.kanal.anlegen") && <NeuerKanal projectId={id} personen={personen} />}
{rechte.darf("chat.kanal.anlegen") && <NeuerKanal projectId={id} personen={personen} />}
<nav className="card overflow-hidden">
<h2 className="border-b border-db-line px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-db-muted">
@@ -157,7 +156,7 @@ export default async function Kommunikation({
)}
</div>
{can(roles, "chat.schreiben") && <Nachrichtenfeld channelId={aktiv.id} kanalName={aktiv.name} />}
{rechte.darf("chat.schreiben") && <Nachrichtenfeld channelId={aktiv.id} kanalName={aktiv.name} />}
</>
) : (
<p className="p-10 text-center text-sm text-db-muted">Für dieses Projekt ist noch kein Kanal angelegt.</p>
+3 -4
View File
@@ -2,7 +2,6 @@ import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { TabLink } from "@/components/NavLink";
import { StatusChip } from "@/components/ui";
import { can, canInProject } from "@/lib/rbac";
import { datum } from "@/lib/format";
export default async function ProjektLayout({
@@ -13,12 +12,12 @@ export default async function ProjektLayout({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const { roles, projekt, optionen, nurEigeneFirma } = await projektKontext(id);
const { rechte, projekt, nurEigeneFirma } = await projektKontext(id);
const darf = rechte.darf;
const abschnitte = await prisma.projectSection.findMany({ where: { projectId: id }, take: 1 });
const a = abschnitte[0];
const base = `/projekte/${id}`;
const darf = (p: Parameters<typeof canInProject>[1]) => canInProject(roles, p, optionen);
const tabs: { href: string; label: string; sichtbar: boolean; exact?: boolean }[] = [
{ href: base, label: "Übersicht", sichtbar: true, exact: true },
@@ -31,7 +30,7 @@ export default async function ProjektLayout({
{ href: `${base}/rechnungen`, label: "Rechnungsprüfung", sichtbar: darf("rechnung.lesen") },
{ href: `${base}/kommunikation`, label: "Kommunikation", sichtbar: true },
{ href: `${base}/beteiligte`, label: "Beteiligte", sichtbar: true },
{ href: `${base}/einstellungen`, label: "Einstellungen", sichtbar: can(roles, "projekt.bearbeiten") },
{ href: `${base}/einstellungen`, label: "Einstellungen", sichtbar: rechte.darf("projekt.bearbeiten") },
];
return (
@@ -2,16 +2,15 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { benachrichtige, protokolliere } from "@/lib/notify";
import type { IssueStatus, IssueType, Severity } from "@prisma/client";
export async function meldungAnlegen(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "vorgang.anlegen");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("vorgang.anlegen");
const letzte = await prisma.issue.findFirst({
where: { projectId },
@@ -68,8 +67,8 @@ export async function meldungAnlegen(formData: FormData) {
export async function meldungStatus(issueId: string, status: IssueStatus, kommentar?: string) {
const user = await requireUser();
const vorgang = await prisma.issue.findUniqueOrThrow({ where: { id: issueId } });
const roles = await rolesInProject(user.id, vorgang.projectId);
assertCan(roles, "vorgang.bearbeiten");
const rechte = await rechteImProjekt(user.id, vorgang.projectId);
rechte.pruefe("vorgang.bearbeiten");
await prisma.issue.update({
where: { id: issueId },
@@ -1,6 +1,5 @@
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can } from "@/lib/rbac";
import { StatusChip, Leer, Kachel } from "@/components/ui";
import { datum, eur, tageBis } from "@/lib/format";
import { NeueMeldung } from "./NeueMeldung";
@@ -31,7 +30,7 @@ export default async function Meldungen({
}) {
const { id } = await params;
const sp = await searchParams;
const { user, roles, nurEigeneFirma } = await projektKontext(id);
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
const [vorgaenge, firmen, mitglieder, vertraege] = await Promise.all([
prisma.issue.findMany({
@@ -93,7 +92,7 @@ export default async function Meldungen({
</Link>
))}
<div className="ml-auto">
{can(roles, "vorgang.anlegen") && (
{rechte.darf("vorgang.anlegen") && (
<NeueMeldung projectId={id} firmen={firmen} personen={personen} vertraege={vertraege} />
)}
</div>
@@ -149,7 +148,7 @@ export default async function Meldungen({
</div>
<div className="shrink-0">
{can(roles, "vorgang.bearbeiten") ? (
{rechte.darf("vorgang.bearbeiten") ? (
<StatusWechsel issueId={v.id} status={v.status} />
) : (
<StatusChip status={v.status} />
+2 -3
View File
@@ -1,7 +1,6 @@
import Link from "next/link";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canInProject } from "@/lib/rbac";
import { Kachel, StatusChip, Fortschritt, Feld } from "@/components/ui";
import { datum, eur, tageBis } from "@/lib/format";
import { CheckCircle2, Circle, CircleDot } from "lucide-react";
@@ -17,9 +16,9 @@ export default async function ProjektUebersicht({
}) {
const { id } = await params;
const { angelegt } = await searchParams;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
const darf = rechte.darf;
const darf = (p: Parameters<typeof canInProject>[1]) => canInProject(roles, p, optionen);
const zeigeVertraege = darf("vertrag.lesen");
const zeigeDokumentation = darf("ablage.lesen");
const zeigeTagebuch = darf("bautagebuch.lesen");
@@ -3,7 +3,6 @@ import { notFound } from "next/navigation";
import { AlertTriangle, ArrowLeft, CheckCircle2, Info } from "lucide-react";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Feld, Kachel, KeinZugriff } from "@/components/ui";
import { datum, datumZeit, eur, zahl } from "@/lib/format";
import { FINDING_LABEL } from "@/lib/rechnungspruefung";
@@ -14,8 +13,8 @@ export const dynamic = "force-dynamic";
export default async function RechnungDetail({ params }: { params: Promise<{ id: string; rid: string }> }) {
const { id, rid } = await params;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
if (!canInProject(roles, "rechnung.lesen", optionen))
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
if (!rechte.darf("rechnung.lesen"))
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
const r = await prisma.invoice.findUnique({
@@ -122,10 +121,10 @@ export default async function RechnungDetail({ params }: { params: Promise<{ id:
vorschlagKuerzung={Number(r.kuerzungNetto)}
netto={Number(r.nettoBetrag)}
// Prüfen und Bescheinigen hängen am Rollenrecht, nicht am Projektschalter
darfPruefen={can(roles, "rechnung.lesen")}
darfSachlich={can(roles, "rechnung.sachlich")}
darfRechnerisch={can(roles, "rechnung.rechnerisch") && r.sachlichById !== user.id}
darfFreigeben={can(roles, "rechnung.freigeben")}
darfPruefen={rechte.darf("rechnung.lesen")}
darfSachlich={rechte.darf("rechnung.sachlich")}
darfRechnerisch={rechte.darf("rechnung.rechnerisch") && r.sachlichById !== user.id}
darfFreigeben={rechte.darf("rechnung.freigeben")}
historie={{
sachlich: r.sachlichBy ? `${r.sachlichBy.name}, ${datumZeit(r.sachlichAm)}` : null,
rechnerisch: r.rechnerischBy ? `${r.rechnerischBy.name}, ${datumZeit(r.rechnerischAm)}` : null,
@@ -189,7 +188,7 @@ export default async function RechnungDetail({ params }: { params: Promise<{ id:
{eur(b.differenz)}
</div>
)}
{!b.erledigt && can(roles, "rechnung.sachlich") && <BefundErledigen findingId={b.id} />}
{!b.erledigt && rechte.darf("rechnung.sachlich") && <BefundErledigen findingId={b.id} />}
</div>
</div>
</li>
@@ -2,16 +2,15 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { pruefeRechnung } from "@/lib/rechnungspruefung";
import { benachrichtige, protokolliere } from "@/lib/notify";
export async function pruefungStarten(invoiceId: string) {
const user = await requireUser();
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId);
assertCan(roles, "rechnung.lesen");
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
rechte.pruefe("rechnung.lesen");
const bericht = await pruefeRechnung(invoiceId);
await protokolliere({
@@ -30,8 +29,8 @@ export async function sachlichRichtig(formData: FormData) {
where: { id: invoiceId },
include: { befunde: { where: { erledigt: false, schwere: "KRITISCH" } } },
});
const roles = await rolesInProject(user.id, rechnung.projectId);
assertCan(roles, "rechnung.sachlich");
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
rechte.pruefe("rechnung.sachlich");
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: rechnung.projectId } });
const ignorieren = formData.get("trotzBefunden") === "on";
@@ -95,8 +94,8 @@ export async function rechnerischRichtig(formData: FormData) {
const user = await requireUser();
const invoiceId = String(formData.get("invoiceId"));
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId);
assertCan(roles, "rechnung.rechnerisch");
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
rechte.pruefe("rechnung.rechnerisch");
if (rechnung.status !== "SACHLICH_RICHTIG")
throw new Error("Die sachliche Richtigkeit muss zuerst durch die Bauüberwachung bescheinigt werden.");
@@ -141,8 +140,8 @@ export async function zahlungFreigeben(formData: FormData) {
const user = await requireUser();
const invoiceId = String(formData.get("invoiceId"));
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId);
assertCan(roles, "rechnung.freigeben");
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
rechte.pruefe("rechnung.freigeben");
if (rechnung.status !== "RECHNERISCH_RICHTIG")
throw new Error("Vor der Zahlungsfreigabe müssen sachliche und rechnerische Prüfung abgeschlossen sein.");
@@ -162,8 +161,8 @@ export async function zurueckweisen(formData: FormData) {
const user = await requireUser();
const invoiceId = String(formData.get("invoiceId"));
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId);
assertCan(roles, "rechnung.sachlich");
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
rechte.pruefe("rechnung.sachlich");
await prisma.invoice.update({
where: { id: invoiceId },
@@ -184,8 +183,8 @@ export async function befundErledigen(formData: FormData) {
where: { id: findingId },
include: { invoice: { select: { projectId: true, id: true } } },
});
const roles = await rolesInProject(user.id, befund.invoice.projectId);
assertCan(roles, "rechnung.sachlich");
const rechte = await rechteImProjekt(user.id, befund.invoice.projectId);
rechte.pruefe("rechnung.sachlich");
await prisma.invoiceFinding.update({
where: { id: findingId },
@@ -2,7 +2,6 @@ import Link from "next/link";
import { AlertTriangle } from "lucide-react";
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canInProject } from "@/lib/rbac";
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
import { datum, eur } from "@/lib/format";
@@ -10,8 +9,8 @@ export const dynamic = "force-dynamic";
export default async function Rechnungen({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
if (!canInProject(roles, "rechnung.lesen", optionen))
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
if (!rechte.darf("rechnung.lesen"))
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
const rechnungen = await prisma.invoice.findMany({
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { requireUser, rechteImProjekt } from "@/lib/auth";
import { benachrichtige, protokolliere } from "@/lib/notify";
/**
@@ -19,8 +18,8 @@ export async function zettelPruefen(formData: FormData) {
where: { id: timeSheetId },
include: { zeilen: true },
});
const roles = await rolesInProject(user.id, zettel.projectId);
assertCan(roles, "stunden.pruefen");
const rechte = await rechteImProjekt(user.id, zettel.projectId);
rechte.pruefe("stunden.pruefen");
let abweichung = false;
for (const z of zettel.zeilen) {
@@ -71,8 +70,8 @@ export async function aufmassPruefen(formData: FormData) {
where: { id: measurementId },
include: { zeilen: true },
});
const roles = await rolesInProject(user.id, aufmass.projectId);
assertCan(roles, "aufmass.pruefen");
const rechte = await rechteImProjekt(user.id, aufmass.projectId);
rechte.pruefe("aufmass.pruefen");
let abweichung = false;
for (const z of aufmass.zeilen) {
@@ -104,8 +103,8 @@ export async function aufmassPruefen(formData: FormData) {
export async function zettelEinreichen(formData: FormData) {
const user = await requireUser();
const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId);
assertCan(roles, "stunden.einreichen");
const rechte = await rechteImProjekt(user.id, projectId);
rechte.pruefe("stunden.einreichen");
const contractId = String(formData.get("contractId"));
const anzahl = await prisma.timeSheet.count({ where: { contractId } });
@@ -1,6 +1,5 @@
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can } from "@/lib/rbac";
import { StatusChip, Kachel, Leer } from "@/components/ui";
import { datum, eur, zahl } from "@/lib/format";
import { ZettelPruefung } from "./ZettelPruefung";
@@ -22,9 +21,9 @@ const ART_LABEL: Record<string, string> = {
export default async function Stunden({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { user, roles, nurEigeneFirma } = await projektKontext(id);
const darfPruefen = can(roles, "stunden.pruefen");
const darfEinreichen = can(roles, "stunden.einreichen");
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
const darfPruefen = rechte.darf("stunden.pruefen");
const darfEinreichen = rechte.darf("stunden.einreichen");
// Datenauswahl hängt ausschließlich an der Rolle – ein Projektschalter
// darf niemals fremde Firmendaten sichtbar machen.
@@ -254,7 +253,7 @@ export default async function Stunden({ params }: { params: Promise<{ id: string
</header>
<div className="p-4">
{a.bemerkung && <p className="mb-2 text-xs text-db-muted">{a.bemerkung}</p>}
{can(roles, "aufmass.pruefen") && a.status === "EINGEREICHT" ? (
{rechte.darf("aufmass.pruefen") && a.status === "EINGEREICHT" ? (
<AufmassPruefung
measurementId={a.id}
zeilen={a.zeilen.map((z) => ({
@@ -1,6 +1,5 @@
import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canInProject } from "@/lib/rbac";
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
import { datum, eur, zahl } from "@/lib/format";
@@ -8,8 +7,8 @@ export const dynamic = "force-dynamic";
export default async function Vertraege({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
if (!canInProject(roles, "vertrag.lesen", optionen))
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
if (!rechte.darf("vertrag.lesen"))
return <KeinZugriff bereich="Der Bereich Verträge & Nachträge" />;
const vertraege = await prisma.contract.findMany({
@@ -19,7 +19,7 @@ export function ProjektFormular({
eigeneId,
}: {
personen: Person[];
rollen: { wert: string; label: string }[];
rollen: { wert: string; label: string; hinweis: string; eigen: boolean; seite: string }[];
fachbereicheNetz: string[];
fachbereicheBahnhof: string[];
masken: { netz: number[]; bahnhof: number[] };
@@ -290,6 +290,7 @@ export function ProjektFormular({
{rollen.map((r) => (
<option key={r.wert} value={r.wert}>
{r.label}
{r.eigen ? " (eigene Rolle)" : ""}
</option>
))}
</select>
+2 -10
View File
@@ -3,8 +3,7 @@
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth";
import { can } from "@/lib/rbac";
import { requireUser, darfProjekteAnlegen } from "@/lib/auth";
import { slotsErzeugen, meilensteineAusTerminen } from "@/lib/projekte";
import { protokolliere, benachrichtige } from "@/lib/notify";
import type { Bauherr, ProjectStatus, Role } from "@prisma/client";
@@ -17,14 +16,7 @@ function datumOderNull(v: FormDataEntryValue | null): Date | null {
export async function projektAnlegen(formData: FormData) {
const user = await requireUser();
// Das Recht ist global – wer irgendwo Projektleitung ist, darf anlegen.
const rollen = await prisma.projectMember.findMany({
where: { userId: user.id },
select: { role: true },
});
const globaleRollen: Role[] = [...new Set(rollen.map((r) => r.role))];
if (user.isAdmin) globaleRollen.push("ADMIN");
if (!can(globaleRollen, "projekt.anlegen")) {
if (!(await darfProjekteAnlegen(user.id, user.isAdmin))) {
throw new Error("Projekte dürfen nur Projektleitung und Administration anlegen.");
}
+7 -11
View File
@@ -3,32 +3,28 @@ import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { can, ROLE_LABEL, OPTION_INFO, type ProjektOptionen } from "@/lib/rbac";
import { OPTION_INFO, type ProjektOptionen } from "@/lib/rbac";
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
import { fachbereichsMasken } from "@/lib/projekte";
import { rollenAuswahl } from "@/lib/rollenauswahl";
import { darfProjekteAnlegen } from "@/lib/auth";
import { ProjektFormular } from "./ProjektFormular";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic";
export default async function NeuesProjekt() {
const user = await requireUser();
const eigene = await prisma.projectMember.findMany({
where: { userId: user.id },
select: { role: true },
});
const rollen: Role[] = [...new Set(eigene.map((r) => r.role))];
if (user.isAdmin) rollen.push("ADMIN");
if (!can(rollen, "projekt.anlegen")) redirect("/projekte?fehler=kein-zugriff");
if (!(await darfProjekteAnlegen(user.id, user.isAdmin))) redirect("/projekte?fehler=kein-zugriff");
const [personen, masken] = await Promise.all([
const [personen, masken, rollen] = await Promise.all([
prisma.user.findMany({
where: { active: true },
select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true, type: true } } },
orderBy: { name: "asc" },
}),
fachbereichsMasken([...FACHBEREICHE_NETZ, ...FACHBEREICHE_BAHNHOF]),
rollenAuswahl(),
]);
const standardOptionen = Object.fromEntries(
@@ -58,7 +54,7 @@ export default async function NeuesProjekt() {
firma: p.company?.shortName ?? p.company?.name ?? null,
extern: p.company?.type === "AUFTRAGNEHMER" || p.company?.type === "SICHERUNGSUNTERNEHMEN",
}))}
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({ wert: r, label: ROLE_LABEL[r] }))}
rollen={rollen}
fachbereicheNetz={FACHBEREICHE_NETZ}
fachbereicheBahnhof={FACHBEREICHE_BAHNHOF}
masken={masken}
+7 -10
View File
@@ -4,8 +4,8 @@ import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { StatusChip, Fortschritt, Leer } from "@/components/ui";
import { datum, eur } from "@/lib/format";
import { ROLE_LABEL, can } from "@/lib/rbac";
import type { Role } from "@prisma/client";
import { rollenBezeichnung } from "@/lib/rbac";
import { darfProjekteAnlegen } from "@/lib/auth";
export const dynamic = "force-dynamic";
@@ -23,7 +23,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
const projekte = await prisma.project.findMany({
where: user.isAdmin ? {} : { members: { some: { userId: user.id } } },
include: {
members: { where: { userId: user.id }, select: { role: true } },
members: { where: { userId: user.id }, select: { role: true, customRole: { select: { name: true } } } },
abschnitte: { take: 1 },
_count: { select: { documents: true, issues: true, contracts: true } },
},
@@ -35,12 +35,9 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
where: { projectId: { in: projekte.map((p) => p.id) } },
_count: true,
});
// Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist, darf anlegen.
const eigeneRollen: Role[] = [
...new Set(projekte.flatMap((p) => p.members.map((m) => m.role))),
...(user.isAdmin ? (["ADMIN"] as Role[]) : []),
];
const darfAnlegen = can(eigeneRollen, "projekt.anlegen");
// Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist
// – über eine Standardrolle oder eine eigene Rolle – darf anlegen.
const darfAnlegen = await darfProjekteAnlegen(user.id, user.isAdmin);
const grad = new Map<string, { gesamt: number; fertig: number; offen: number }>();
for (const s of slotStat) {
@@ -120,7 +117,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
</dl>
<div className="mt-3 flex items-center justify-between text-[11px] text-db-muted">
<span>{p.members.map((m) => ROLE_LABEL[m.role]).join(", ") || "Administration"}</span>
<span>{p.members.map((m) => rollenBezeichnung(m)).join(", ") || "Administration"}</span>
<span>IBN {datum(p.inbetriebnahme)}</span>
</div>
</Link>
+15 -12
View File
@@ -4,8 +4,7 @@ import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { Kachel, StatusChip, Leer, Fortschritt } from "@/components/ui";
import { datum, eur, tageBis } from "@/lib/format";
import { ROLE_LABEL, isExternalOnly } from "@/lib/rbac";
import type { Role } from "@prisma/client";
import { rollenBezeichnung, ROLE_SEITE } from "@/lib/rbac";
export const dynamic = "force-dynamic";
@@ -14,7 +13,10 @@ export default async function Uebersicht() {
const mitgliedschaften = await prisma.projectMember.findMany({
where: { userId: user.id },
include: { project: { select: { id: true, nummer: true, name: true, kurzname: true, status: true } } },
include: {
project: { select: { id: true, nummer: true, name: true, kurzname: true, status: true } },
customRole: { select: { name: true, seite: true } },
},
});
const projektIds = [...new Set(mitgliedschaften.map((m) => m.projectId))];
@@ -22,15 +24,16 @@ export default async function Uebersicht() {
return <Leer text="Ihnen ist derzeit kein Projekt zugeordnet. Bitte wenden Sie sich an die Projektleitung." />;
}
// Projekte trennen: in welchen ist der Nutzer auf der Bauherrenseite,
// in welchen Auftragnehmer? Für Letztere gilt überall die Firmenschranke.
const rollenJeProjekt = new Map<string, Role[]>();
// Projekte trennen: in welchen steht der Nutzer auf der Bauherrenseite,
// in welchen auf der Auftragnehmerseite? Für Letztere gilt die Firmenschranke.
// Standardrollen und eigene Rollen werden gleich behandelt – maßgeblich ist
// allein die hinterlegte Seite.
const aufAGSeite = new Map<string, boolean>();
for (const m of mitgliedschaften) {
rollenJeProjekt.set(m.projectId, [...(rollenJeProjekt.get(m.projectId) ?? []), m.role]);
const seite = m.role ? ROLE_SEITE[m.role] : (m.customRole?.seite ?? "NEUTRAL");
if (seite === "AG" || m.role === "ADMIN") aufAGSeite.set(m.projectId, true);
}
const intern = projektIds.filter(
(pid) => user.isAdmin || !isExternalOnly(rollenJeProjekt.get(pid) ?? []),
);
const intern = projektIds.filter((pid) => user.isAdmin || aufAGSeite.get(pid) === true);
const extern = projektIds.filter((pid) => !intern.includes(pid));
const eigeneFirma = user.companyId ?? "__keine__";
@@ -124,14 +127,14 @@ export default async function Uebersicht() {
proProjekt.set(s.projectId, e);
}
const rollen = [...new Set(mitgliedschaften.map((m) => m.role))];
const rollen = [...new Set(mitgliedschaften.map((m) => rollenBezeichnung(m)))];
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-white">Guten Tag, {user.name.split(" ")[0]}.</h1>
<p className="mt-0.5 text-sm text-db-muted">
{rollen.map((r) => ROLE_LABEL[r]).join(" · ")} · {projektIds.length}{" "}
{rollen.join(" · ")} · {projektIds.length}{" "}
{projektIds.length === 1 ? "Projekt" : "Projekte"}
</p>
</div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ROLE_LABEL } from "@/lib/rbac";
import { rollenBezeichnung } from "@/lib/rbac";
import { Benutzerliste } from "./Benutzerliste";
export const dynamic = "force-dynamic";
@@ -12,7 +12,7 @@ export default async function BenutzerVerwaltung() {
prisma.user.findMany({
include: {
company: { select: { id: true, name: true, shortName: true } },
memberships: { select: { role: true, project: { select: { nummer: true } } } },
memberships: { select: { role: true, customRole: { select: { name: true } }, project: { select: { nummer: true } } } },
_count: { select: { sessions: true } },
},
orderBy: [{ active: "desc" }, { name: "asc" }],
@@ -40,7 +40,7 @@ export default async function BenutzerVerwaltung() {
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]))],
rollen: [...new Set(b.memberships.map((m) => rollenBezeichnung(m)))],
projekte: b.memberships.length,
}))}
firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))}
@@ -0,0 +1,395 @@
"use client";
import { Fragment, useEffect, useMemo, useState, useTransition } from "react";
import { Check, Copy, Lock, Pencil, Plus, Power, Shield, Trash2, X } from "lucide-react";
import { rolleSpeichern, rolleStatus, rolleLoeschen } from "./actions";
export type RechtInfo = { schluessel: string; label: string; gruppe: string; gesperrt: boolean };
export type EigeneRolleAnzeige = {
id: string;
name: string;
beschreibung: string | null;
seite: string;
vorlage: string | null;
vorlageLabel: string | null;
rechte: string[];
aktiv: boolean;
vergeben: number;
};
export type Vorlage = { wert: string; label: string; seite: string; rechte: string[] };
const SEITEN: [string, string, string][] = [
["AG", "Auftraggeberseite", "sieht das gesamte Projekt – für Rollen des Bauherrn"],
["AN", "Auftragnehmerseite", "sieht ausschließlich Daten der eigenen Firma"],
["NEUTRAL", "Zuarbeit / Mitlesen", "ebenfalls auf die eigene Firma beschränkt"],
];
export function Rolleneditor({
eigene,
rechte,
gruppen,
vorlagen,
}: {
eigene: EigeneRolleAnzeige[];
rechte: RechtInfo[];
gruppen: string[];
vorlagen: Vorlage[];
}) {
const [modus, setModus] = useState<null | { art: "neu" } | { art: "bearbeiten"; rolle: EigeneRolleAnzeige }>(null);
const [fehler, setFehler] = useState<string | null>(null);
const [pending, start] = useTransition();
const lauf = (fn: () => Promise<unknown>) =>
start(async () => {
setFehler(null);
try {
await fn();
setModus(null);
} catch (e) {
setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich.");
}
});
return (
<section className="card overflow-hidden">
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-db-line px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-white">Eigene Rollen</h2>
<p className="mt-0.5 max-w-2xl text-xs leading-relaxed text-db-muted">
Die elf Standardrollen sind Teil des Programms und lassen sich weder ändern noch
löschen – sie bilden den Vorgabeprozess ab und sind die Bezugsgröße, auf die sich
Prüfvermerke und Bescheinigungen berufen. Zusätzliche Rollen entstehen hier,
wahlweise auf Grundlage einer Standardrolle.
</p>
</div>
<button
onClick={() => { setModus(modus?.art === "neu" ? null : { art: "neu" }); setFehler(null); }}
className="btn-primary shrink-0"
>
<Plus size={15} /> Rolle anlegen
</button>
</header>
{fehler && (
<p className="border-b border-db-line bg-db-red/10 px-4 py-2.5 text-sm text-red-300">{fehler}</p>
)}
{modus?.art === "neu" && (
<RolleFormular
rechte={rechte}
gruppen={gruppen}
vorlagen={vorlagen}
pending={pending}
onAbbrechen={() => setModus(null)}
onSpeichern={(fd) => lauf(() => rolleSpeichern(fd))}
/>
)}
{eigene.length === 0 && modus?.art !== "neu" ? (
<p className="px-4 py-6 text-sm text-db-muted">
Noch keine eigene Rolle angelegt. Die Standardrollen decken den üblichen Ablauf ab –
eine eigene Rolle lohnt sich etwa für eine Bauüberwachung ohne Bescheinigungsrecht
oder für ein Ingenieurbüro, das zusätzlich Aufmaße prüfen soll.
</p>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Rolle</th>
<th className="th w-44">Seite</th>
<th className="th w-40">Vorlage</th>
<th className="th w-24 text-right">Rechte</th>
<th className="th w-24 text-right">vergeben</th>
<th className="th w-52 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{eigene.map((r) => (
<Fragment key={r.id}>
<tr className={`row ${!r.aktiv ? "opacity-50" : ""}`}>
<td className="td">
<div className="flex items-center gap-2">
<span className="text-slate-100">{r.name}</span>
{!r.aktiv && (
<span className="chip border-slate-700 bg-slate-800 text-slate-500">stillgelegt</span>
)}
</div>
{r.beschreibung && (
<div className="mt-0.5 max-w-2xl text-[11px] leading-relaxed text-db-muted">{r.beschreibung}</div>
)}
</td>
<td className="td">
<SeitenChip seite={r.seite} />
</td>
<td className="td text-xs text-db-muted">{r.vorlageLabel ?? "von Grund auf"}</td>
<td className="td text-right tabular-nums text-slate-300">{r.rechte.length}</td>
<td className="td text-right tabular-nums text-slate-300">{r.vergeben}×</td>
<td className="td">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => { setModus({ art: "bearbeiten", rolle: r }); setFehler(null); }}
className="btn-ghost btn-sm"
>
<Pencil size={12} /> bearbeiten
</button>
<button
disabled={pending}
onClick={() => lauf(() => rolleStatus(r.id, !r.aktiv))}
className="btn-ghost btn-sm"
title={r.aktiv ? "stilllegen" : "wieder freigeben"}
>
<Power size={12} className={r.aktiv ? "" : "text-emerald-400"} />
</button>
<button
disabled={pending || r.vergeben > 0}
onClick={() => {
if (confirm(`Rolle „${r.name}“ endgültig löschen?`)) lauf(() => rolleLoeschen(r.id));
}}
className="btn-ghost btn-sm disabled:opacity-30"
title={r.vergeben > 0 ? "noch vergeben – nicht löschbar" : "löschen"}
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
{modus?.art === "bearbeiten" && modus.rolle.id === r.id && (
<tr className="border-t border-db-line bg-db-dark/40">
<td className="td" colSpan={6}>
<RolleFormular
rolle={r}
rechte={rechte}
gruppen={gruppen}
vorlagen={vorlagen}
pending={pending}
onAbbrechen={() => setModus(null)}
onSpeichern={(fd) => lauf(() => rolleSpeichern(fd))}
/>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
</section>
);
}
function SeitenChip({ seite }: { seite: string }) {
const cls =
seite === "AG"
? "border-db-cyan/50 bg-db-cyan/10 text-db-cyan"
: seite === "AN"
? "border-amber-700 bg-amber-900/25 text-amber-300"
: "border-db-line bg-db-dark text-slate-400";
const text = seite === "AG" ? "Auftraggeberseite" : seite === "AN" ? "Auftragnehmerseite" : "Zuarbeit";
return <span className={`chip ${cls}`}>{text}</span>;
}
function RolleFormular({
rolle,
rechte,
gruppen,
vorlagen,
pending,
onAbbrechen,
onSpeichern,
}: {
rolle?: EigeneRolleAnzeige;
rechte: RechtInfo[];
gruppen: string[];
vorlagen: Vorlage[];
pending: boolean;
onAbbrechen: () => void;
onSpeichern: (fd: FormData) => void;
}) {
const [gewaehlt, setGewaehlt] = useState<string[]>(rolle?.rechte ?? []);
const [vorlage, setVorlage] = useState(rolle?.vorlage ?? "");
const [seite, setSeite] = useState(rolle?.seite ?? "NEUTRAL");
const [vorlageUebernommen, setVorlageUebernommen] = useState<string | null>(null);
const frei = useMemo(() => rechte.filter((r) => !r.gesperrt), [rechte]);
// Vorlage übernehmen: Rechte und Seite der Standardrolle setzen
useEffect(() => {
if (!vorlage || rolle) return;
const v = vorlagen.find((x) => x.wert === vorlage);
if (!v) return;
setGewaehlt(v.rechte.filter((r) => frei.some((f) => f.schluessel === r)));
setSeite(v.seite);
setVorlageUebernommen(v.label);
}, [vorlage, vorlagen, frei, rolle]);
const toggle = (k: string) =>
setGewaehlt((g) => (g.includes(k) ? g.filter((x) => x !== k) : [...g, k]));
return (
<form action={onSpeichern} className="space-y-4 p-1">
{rolle && <input type="hidden" name="rolleId" value={rolle.id} />}
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="label mb-1.5 block">Bezeichnung</label>
<input
name="name"
required
defaultValue={rolle?.name ?? ""}
placeholder="z. B. Bauüberwachung ohne Bescheinigung"
className="input"
/>
</div>
{!rolle && (
<div>
<label className="label mb-1.5 block">Vorlage</label>
<select
name="vorlage"
value={vorlage}
onChange={(e) => setVorlage(e.target.value)}
className="input"
>
<option value="">ohne Vorlage – von Grund auf</option>
{vorlagen.map((v) => (
<option key={v.wert} value={v.wert}>
{v.label} ({v.rechte.length} Rechte)
</option>
))}
</select>
<p className="mt-1 text-[11px] text-db-muted">
Übernimmt die Rechte der Standardrolle als Ausgangspunkt.
</p>
</div>
)}
<div className={rolle ? "sm:col-span-2" : ""}>
<label className="label mb-1.5 block">Seite des Bauvertrags</label>
<select name="seite" value={seite} onChange={(e) => setSeite(e.target.value)} className="input">
{SEITEN.map(([k, l]) => (
<option key={k} value={k}>
{l}
</option>
))}
</select>
<p className="mt-1 text-[11px] leading-relaxed text-db-muted">
{SEITEN.find(([k]) => k === seite)?.[2]}
</p>
</div>
<div className="sm:col-span-3">
<label className="label mb-1.5 block">Beschreibung</label>
<input
name="beschreibung"
defaultValue={rolle?.beschreibung ?? ""}
placeholder="Wofür ist diese Rolle gedacht? Erscheint bei der Zuordnung im Projekt."
className="input"
/>
</div>
</div>
{vorlageUebernommen && (
<p className="rounded-md border border-db-cyan/40 bg-db-cyan/10 px-3 py-2 text-xs text-db-cyan">
<Copy size={12} className="mr-1.5 inline" />
Rechte und Seite von „{vorlageUebernommen}“ übernommen – jetzt anpassen.
</p>
)}
{seite === "AN" && (
<p className="rounded-md border border-amber-800/50 bg-amber-950/15 px-3 py-2 text-xs leading-relaxed text-amber-200/85">
<Shield size={12} className="mr-1.5 inline" />
Auf der Auftragnehmerseite gilt unabhängig von den hier gesetzten Rechten: Es werden
ausschließlich Daten der eigenen Firma angezeigt, und die Sichtbarkeit der Bereiche
hängt zusätzlich an den Schaltern des jeweiligen Projekts.
</p>
)}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="label">Rechte ({gewaehlt.length} gewählt)</span>
<div className="flex gap-1.5">
<button type="button" onClick={() => setGewaehlt(frei.map((r) => r.schluessel))} className="btn-ghost btn-sm">
alle
</button>
<button type="button" onClick={() => setGewaehlt([])} className="btn-ghost btn-sm">
keine
</button>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{gruppen.map((g) => {
const inGruppe = rechte.filter((r) => r.gruppe === g);
if (inGruppe.length === 0) return null;
const freiInGruppe = inGruppe.filter((r) => !r.gesperrt);
const alleAn = freiInGruppe.length > 0 && freiInGruppe.every((r) => gewaehlt.includes(r.schluessel));
return (
<div key={g} className="rounded-md border border-db-line">
<div className="flex items-center justify-between border-b border-db-line bg-db-dark/40 px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-db-muted">{g}</span>
{freiInGruppe.length > 0 && (
<button
type="button"
onClick={() =>
setGewaehlt((cur) =>
alleAn
? cur.filter((x) => !freiInGruppe.some((r) => r.schluessel === x))
: [...new Set([...cur, ...freiInGruppe.map((r) => r.schluessel)])],
)
}
className="text-[10px] text-db-muted hover:text-db-cyan"
>
{alleAn ? "abwählen" : "alle"}
</button>
)}
</div>
<div className="space-y-0.5 p-2">
{inGruppe.map((r) =>
r.gesperrt ? (
<div
key={r.schluessel}
className="flex items-start gap-2 px-1 py-1 text-sm text-slate-600"
title="Wird ausschließlich am Zugang selbst vergeben"
>
<Lock size={13} className="mt-0.5 shrink-0" />
<span className="line-through">{r.label}</span>
</div>
) : (
<label
key={r.schluessel}
className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 text-sm text-slate-300 hover:bg-db-line/40"
>
<input
type="checkbox"
name="rechte"
value={r.schluessel}
checked={gewaehlt.includes(r.schluessel)}
onChange={() => toggle(r.schluessel)}
className="mt-1"
/>
<span>{r.label}</span>
</label>
),
)}
</div>
</div>
);
})}
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={pending || gewaehlt.length === 0} className="btn-primary">
<Check size={14} /> {rolle ? "Änderungen speichern" : "Rolle anlegen"}
</button>
<button type="button" onClick={onAbbrechen} className="btn-ghost">
<X size={14} /> Abbrechen
</button>
{gewaehlt.length === 0 && (
<span className="self-center text-xs text-db-muted">Mindestens ein Recht auswählen.</span>
)}
</div>
</form>
);
}
@@ -0,0 +1,117 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth";
import { bereinigeRechte, NICHT_FREI_VERGEBBAR, PERMISSION_INFO } from "@/lib/rbac";
import { protokolliere } from "@/lib/notify";
import type { Role, RolleSeite } from "@prisma/client";
async function nurAdmin() {
const user = await requireUser();
if (!user.isAdmin) throw new Error("Diese Funktion ist der Administration vorbehalten.");
return user;
}
function schluesselAus(name: string): string {
return name
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "rolle";
}
export async function rolleSpeichern(formData: FormData) {
const admin = await nurAdmin();
const id = String(formData.get("rolleId") ?? "");
const name = String(formData.get("name") ?? "").trim();
if (name.length < 3) throw new Error("Bitte einen aussagekräftigen Rollennamen angeben.");
const gewaehlt = formData.getAll("rechte").map(String);
const abgewiesen = gewaehlt.filter((r) => NICHT_FREI_VERGEBBAR.includes(r as never));
if (abgewiesen.length > 0) {
const namen = abgewiesen.map((r) => PERMISSION_INFO[r as keyof typeof PERMISSION_INFO]?.label ?? r);
throw new Error(
`Diese Rechte lassen sich nicht über eine Rolle vergeben: ${namen.join(", ")}. ` +
"Die Administration wird ausschließlich am Zugang selbst gesetzt.",
);
}
const rechte = bereinigeRechte(gewaehlt);
if (rechte.length === 0) throw new Error("Eine Rolle ohne jedes Recht ergibt keinen Sinn.");
const seite = (formData.get("seite") as RolleSeite) || "NEUTRAL";
const vorlageRoh = String(formData.get("vorlage") ?? "");
const vorlage = vorlageRoh ? (vorlageRoh as Role) : null;
const beschreibung = String(formData.get("beschreibung") ?? "").trim() || null;
if (id) {
const vorher = await prisma.customRole.findUniqueOrThrow({ where: { id } });
const entzogen = vorher.rechte.filter((r) => !rechte.includes(r as never));
await prisma.customRole.update({
where: { id },
data: { name, beschreibung, seite, rechte },
});
await protokolliere({
userId: admin.id, aktion: "ROLLE_GEAENDERT", entitaet: "CustomRole", entitaetId: id,
details: { name, seite, anzahlRechte: rechte.length, entzogen },
});
} else {
// Schlüssel eindeutig machen, ohne dem Anwender eine Fehlermeldung zuzumuten
let schluessel = schluesselAus(name);
for (let i = 2; await prisma.customRole.findUnique({ where: { schluessel } }); i++) {
schluessel = `${schluesselAus(name)}-${i}`;
}
const neu = await prisma.customRole.create({
data: { schluessel, name, beschreibung, seite, vorlage, rechte, createdById: admin.id },
});
await protokolliere({
userId: admin.id, aktion: "ROLLE_ANGELEGT", entitaet: "CustomRole", entitaetId: neu.id,
details: { name, seite, vorlage, anzahlRechte: rechte.length },
});
}
revalidatePath("/verwaltung/rollen");
}
export async function rolleStatus(rolleId: string, aktiv: boolean) {
const admin = await nurAdmin();
const rolle = await prisma.customRole.findUniqueOrThrow({
where: { id: rolleId },
include: { _count: { select: { mitglieder: true } } },
});
if (!aktiv && rolle._count.mitglieder > 0) {
throw new Error(
`„${rolle.name}“ ist derzeit ${rolle._count.mitglieder}× in Projekten vergeben. ` +
"Bitte zuerst die Zuordnungen auflösen.",
);
}
await prisma.customRole.update({ where: { id: rolleId }, data: { aktiv } });
await protokolliere({
userId: admin.id, aktion: aktiv ? "ROLLE_AKTIV" : "ROLLE_STILLGELEGT",
entitaet: "CustomRole", entitaetId: rolleId, details: { name: rolle.name },
});
revalidatePath("/verwaltung/rollen");
}
export async function rolleLoeschen(rolleId: string) {
const admin = await nurAdmin();
const rolle = await prisma.customRole.findUniqueOrThrow({
where: { id: rolleId },
include: { _count: { select: { mitglieder: true } } },
});
if (rolle._count.mitglieder > 0)
throw new Error(
`„${rolle.name}“ ist noch ${rolle._count.mitglieder}× vergeben und kann nicht gelöscht werden.`,
);
await prisma.customRole.delete({ where: { id: rolleId } });
await protokolliere({
userId: admin.id, aktion: "ROLLE_GELOESCHT", entitaet: "CustomRole",
entitaetId: rolleId, details: { name: rolle.name, rechte: rolle.rechte },
});
revalidatePath("/verwaltung/rollen");
}
+67 -4
View File
@@ -3,8 +3,10 @@ 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,
ROLE_KURZ, ROLE_SEITE, OPTION_INFO, can, rechteDerRolle, NICHT_FREI_VERGEBBAR,
type Permission,
} from "@/lib/rbac";
import { Rolleneditor } from "./Rolleneditor";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic";
@@ -22,7 +24,13 @@ export default async function Rollenlegende() {
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 [belegung, eigeneRollen] = await Promise.all([
prisma.projectMember.groupBy({ by: ["role"], _count: true }),
prisma.customRole.findMany({
include: { _count: { select: { mitglieder: true } } },
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
}),
]);
const anzahl = new Map(belegung.map((b) => [b.role, b._count]));
return (
@@ -43,7 +51,37 @@ export default async function Rollenlegende() {
</p>
</section>
{/* ── Rollen ───────────────────────────────────────────── */}
{/* ── Rolleneditor ─────────────────────────────────────── */}
<Rolleneditor
eigene={eigeneRollen.map((r) => ({
id: r.id,
name: r.name,
beschreibung: r.beschreibung,
seite: r.seite,
vorlage: r.vorlage,
vorlageLabel: r.vorlage ? ROLE_LABEL[r.vorlage] : null,
rechte: r.rechte,
aktiv: r.aktiv,
vergeben: r._count.mitglieder,
}))}
rechte={rechte.map((p) => ({
schluessel: p,
label: PERMISSION_INFO[p].label,
gruppe: PERMISSION_INFO[p].gruppe,
gesperrt: NICHT_FREI_VERGEBBAR.includes(p),
}))}
gruppen={[...PERMISSION_GRUPPEN]}
vorlagen={rollen
.filter((r) => r !== "ADMIN")
.map((r) => ({
wert: r,
label: ROLE_LABEL[r],
seite: ROLE_SEITE[r],
rechte: rechteDerRolle(r).filter((p) => !NICHT_FREI_VERGEBBAR.includes(p)),
}))}
/>
{/* ── Standardrollen ───────────────────────────────────── */}
{(["AG", "AN", "NEUTRAL"] as const).map((seite) => {
const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite);
if (gruppe.length === 0) return null;
@@ -51,6 +89,9 @@ export default async function Rollenlegende() {
<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="chip border-db-line bg-db-dark text-[10px] text-db-muted">
Standardrollen · unveränderlich
</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"}
@@ -100,6 +141,11 @@ export default async function Rollenlegende() {
{ROLE_KURZ[r]}
</th>
))}
{eigeneRollen.filter((e) => e.aktiv).map((e) => (
<th key={e.id} className="th text-center text-db-cyan" title={`${e.name} (eigene Rolle)`}>
{e.name.slice(0, 6)}…
</th>
))}
</tr>
</thead>
<tbody>
@@ -109,7 +155,7 @@ export default async function Rollenlegende() {
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}>
<td className="td sticky left-0 bg-db-dark/40 text-[11px] font-semibold uppercase tracking-wider text-db-muted" colSpan={rollen.length + eigeneRollen.filter((e) => e.aktiv).length + 1}>
{gruppe}
</td>
</tr>
@@ -140,6 +186,21 @@ export default async function Rollenlegende() {
</td>
);
})}
{eigeneRollen.filter((e) => e.aktiv).map((e) => {
const erlaubt = e.rechte.includes(p);
const optional = !erlaubt && e.seite !== "AG" && !!info.optional;
return (
<td key={e.id} 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>
);
})}
@@ -206,6 +267,8 @@ export default async function Rollenlegende() {
<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>
<li>· Die elf Standardrollen sind Teil des Programms und weder änderbar noch löschbar.</li>
<li>· Administrationsrechte lassen sich nicht über eine eigene Rolle vergeben.</li>
</ul>
</section>
</div>
+4 -5
View File
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser, rolesInProject } from "@/lib/auth";
import { isExternalOnly } from "@/lib/rbac";
import { getSessionUser, rechteImProjekt } from "@/lib/auth";
import { leseDatei } from "@/lib/storage";
import { protokolliere } from "@/lib/notify";
@@ -16,9 +15,9 @@ export async function GET(_req: Request, { params }: { params: Promise<{ version
});
if (!version) return new NextResponse("Nicht gefunden.", { status: 404 });
const roles = await rolesInProject(user.id, version.document.projectId);
if (roles.length === 0) return new NextResponse("Kein Zugriff.", { status: 403 });
if (version.document.vertraulich !== "OFFEN" && isExternalOnly(roles))
const rechte = await rechteImProjekt(user.id, version.document.projectId);
if (rechte.leer) return new NextResponse("Kein Zugriff.", { status: 403 });
if (version.document.vertraulich !== "OFFEN" && rechte.nurEigeneFirma)
return new NextResponse("Kein Zugriff auf diese Unterlage.", { status: 403 });
let daten: Buffer;
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser, rolesInProject } from "@/lib/auth";
import { getSessionUser, rechteImProjekt } from "@/lib/auth";
import { UEBERGABE_LABEL } from "@/lib/fristen";
/** Export des Soll-Ist-Standes – bewusst im Format der Ursprungs-Excel. */
@@ -8,7 +8,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
const { id } = await params;
const user = await getSessionUser();
if (!user) return new NextResponse("Nicht angemeldet.", { status: 401 });
if ((await rolesInProject(user.id, id)).length === 0)
if ((await rechteImProjekt(user.id, id)).leer)
return new NextResponse("Kein Zugriff.", { status: 403 });
const akte = new URL(req.url).searchParams.get("akte");
+59 -10
View File
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
import { randomBytes } from "crypto";
import { prisma } from "./db";
import type { Role } from "@prisma/client";
import { rechtelage, rechteDerRolle, OPTIONEN_OFFEN, type Rechtelage, type EigeneRolle } from "./rbac";
const COOKIE = "baudoc_session";
const SESSION_DAYS = 12;
@@ -109,16 +110,47 @@ export async function rolesInProject(userId: string, projectId: string): Promise
where: { userId, projectId },
select: { role: true },
});
const roles = memberships.map((m) => m.role);
const roles = memberships.map((m) => m.role).filter((r): r is Role => !!r);
if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
return roles;
}
/**
* Die vollständige Rechtelage einer Person in einem Projekt: Standardrollen
* und selbst angelegte Rollen zusammengeführt, Projektschalter berücksichtigt.
*
* Dies ist die einzige Stelle, an der Berechtigungen ermittelt werden. Wer
* eine Prüfung braucht, holt sich diese Lage – nicht die rohen Rollen.
*/
export async function rechteImProjekt(userId: string, projectId: string): Promise<Rechtelage> {
const [user, mitgliedschaften, projekt] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } }),
prisma.projectMember.findMany({
where: { userId, projectId },
include: { customRole: true },
}),
prisma.project.findUnique({ where: { id: projectId } }),
]);
const rollen = mitgliedschaften.map((m) => m.role).filter((r): r is Role => !!r);
if (user?.isAdmin && !rollen.includes("ADMIN")) rollen.push("ADMIN");
const eigene: EigeneRolle[] = mitgliedschaften
.map((m) => m.customRole)
.filter((c): c is NonNullable<typeof c> => !!c && c.aktiv)
.map((c) => ({ id: c.id, name: c.name, seite: c.seite as EigeneRolle["seite"], rechte: c.rechte }));
const { optionenAus } = await import("./projekte");
const optionen = projekt ? optionenAus(projekt) : OPTIONEN_OFFEN;
return rechtelage(rollen, eigene, optionen);
}
export async function requireProjectAccess(projectId: string) {
const user = await requireUser();
const roles = await rolesInProject(user.id, projectId);
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
return { user, roles };
const rechte = await rechteImProjekt(user.id, projectId);
if (rechte.leer) redirect("/projekte?fehler=kein-zugriff");
return { user, rechte };
}
/**
@@ -128,21 +160,38 @@ export async function requireProjectAccess(projectId: string) {
*/
export async function projektKontext(projectId: string) {
const user = await requireUser();
const roles = await rolesInProject(user.id, projectId);
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
const projekt = await prisma.project.findUnique({ where: { id: projectId } });
if (!projekt) redirect("/projekte?fehler=nicht-gefunden");
const rechte = await rechteImProjekt(user.id, projectId);
if (rechte.leer) redirect("/projekte?fehler=kein-zugriff");
const { optionenAus } = await import("./projekte");
const { isExternalOnly } = await import("./rbac");
return {
user,
roles,
rechte,
projekt,
optionen: optionenAus(projekt),
/** true = darf ausschließlich Daten der eigenen Firma sehen */
nurEigeneFirma: isExternalOnly(roles),
nurEigeneFirma: rechte.nurEigeneFirma,
};
}
/**
* Das Recht, Projekte anzulegen, ist nicht projektbezogen: Wer irgendwo
* Projektleitung ist – über eine Standardrolle oder eine eigene Rolle mit
* diesem Recht – darf ein neues Projekt anlegen.
*/
export async function darfProjekteAnlegen(userId: string, isAdmin: boolean): Promise<boolean> {
if (isAdmin) return true;
const mitgliedschaften = await prisma.projectMember.findMany({
where: { userId },
include: { customRole: { select: { rechte: true, aktiv: true } } },
});
return mitgliedschaften.some(
(m) =>
(m.role && rechteDerRolle(m.role).includes("projekt.anlegen")) ||
(m.customRole?.aktiv && m.customRole.rechte.includes("projekt.anlegen")),
);
}
+110 -36
View File
@@ -142,6 +142,15 @@ export const PERMISSION_GRUPPEN = [
"Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration",
] as const;
/** Anzeigename einer Zuordnung – Standardrolle oder selbst angelegte Rolle. */
export function rollenBezeichnung(m: {
role: Role | null;
customRole?: { name: string } | null;
}): string {
if (m.role) return ROLE_LABEL[m.role];
return m.customRole?.name ?? "ohne Rolle";
}
export const ROLE_KURZ: Record<Role, string> = {
ADMIN: "ADM",
PROJEKTLEITER: "PL",
@@ -199,22 +208,44 @@ export const ROLE_HINT: Record<Role, string> = {
LESER: "Nur lesen.",
};
/**
* Auftragnehmer und andere Externe sehen ausschließlich Daten der eigenen Firma.
*
* Wichtig: Diese Funktion steuert die DATENAUSWAHL und darf niemals durch eine
* Projekteinstellung aufgeweicht werden. Die Schalter unter `ProjektOptionen`
* entscheiden nur, ob ein Bereich überhaupt sichtbar ist – nicht, wessen Daten
* darin erscheinen.
*/
export function isExternalOnly(roles: Role[]): boolean {
const privileged: Role[] = [
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
"FACHBAUUEBERWACHER", "KAUFMANN",
];
return roles.length > 0 && !roles.some((r) => privileged.includes(r));
// ═══════════════════════════════════════════════════════════════════
// Rechte einer Rolle
// ═══════════════════════════════════════════════════════════════════
/** Alle Rechte, die eine Standardrolle mitbringt. */
export function rechteDerRolle(role: Role): Permission[] {
return (Object.keys(PERMISSIONS) as Permission[]).filter((p) =>
(PERMISSIONS[p] as readonly Role[]).includes(role),
);
}
/**
* Rechte, die eine selbst angelegte Rolle niemals enthalten darf.
* Die Administration wird ausschließlich über das Kennzeichen am Zugang
* vergeben – sonst ließe sich über eine eigene Rolle die Benutzerverwaltung
* erreichen und damit jede andere Schranke aushebeln.
*/
export const NICHT_FREI_VERGEBBAR: Permission[] = ["admin.benutzer", "admin.firmen", "admin.audit"];
export function istFreiVergebbar(p: string): p is Permission {
return p in PERMISSION_INFO && !NICHT_FREI_VERGEBBAR.includes(p as Permission);
}
/** Bereinigt eine Rechteauswahl: nur bekannte und frei vergebbare Schlüssel. */
export function bereinigeRechte(rechte: string[]): Permission[] {
return [...new Set(rechte.filter(istFreiVergebbar))] as Permission[];
}
export type Seite = "AG" | "AN" | "NEUTRAL";
/** Eine selbst angelegte Rolle, so wie die Rechteprüfung sie braucht. */
export type EigeneRolle = {
id: string;
name: string;
seite: Seite;
rechte: string[];
};
// ═══════════════════════════════════════════════════════════════════
// Projektbezogene Einstellungen
// ═══════════════════════════════════════════════════════════════════
@@ -288,34 +319,77 @@ const ENTZIEHBAR_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>
"dokument.hochladen": "anDarfDokumente",
};
/**
* Berechtigung unter Berücksichtigung der Projekteinstellungen.
* Für Rollen der Bauherrenseite identisch mit `can()`.
*/
export function canInProject(
roles: Role[],
permission: Permission,
optionen: ProjektOptionen,
): boolean {
const extern = isExternalOnly(roles);
// ═══════════════════════════════════════════════════════════════════
// Rechtelage einer Person in einem Projekt
// ═══════════════════════════════════════════════════════════════════
if (extern) {
/**
* Bündelt, was jemand in einem Projekt darf – aus Standardrollen und eigenen
* Rollen zusammengeführt. Einzige Stelle, an der Rechte ausgewertet werden.
*/
export type Rechtelage = {
/** zugewiesene Standardrollen */
rollen: Role[];
/** zugewiesene selbst angelegte Rollen */
eigene: EigeneRolle[];
/** keinerlei Zuordnung – kein Zugriff auf das Projekt */
leer: boolean;
/** true = sieht ausschließlich Daten der eigenen Firma */
nurEigeneFirma: boolean;
/** Klartext aller Rollen, für Anzeige */
bezeichnungen: string[];
/** Rechteprüfung inklusive der Projektschalter */
darf: (permission: Permission) => boolean;
/** wie darf(), wirft aber bei fehlender Berechtigung */
pruefe: (permission: Permission) => void;
};
export function rechtelage(
rollen: Role[],
eigene: EigeneRolle[],
optionen: ProjektOptionen,
): Rechtelage {
const leer = rollen.length === 0 && eigene.length === 0;
// Bauherrenseite: mindestens eine Rolle steht auf der Auftraggeberseite.
const aufAGSeite =
rollen.some((r) => ROLE_SEITE[r] === "AG" || r === "ADMIN") ||
eigene.some((e) => e.seite === "AG");
const nurEigeneFirma = !leer && !aufAGSeite;
const grundmenge = new Set<string>();
for (const r of rollen) for (const p of rechteDerRolle(r)) grundmenge.add(p);
for (const e of eigene) for (const p of e.rechte) grundmenge.add(p);
const darf = (permission: Permission): boolean => {
if (nurEigeneFirma) {
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
if (entzug && !optionen[entzug]) return false;
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
if (gewaehrung && optionen[gewaehrung]) return true;
if (gewaehrung && optionen[gewaehrung] && grundmenge.size > 0) return true;
}
return grundmenge.has(permission);
};
return can(roles, permission);
return {
rollen,
eigene,
leer,
nurEigeneFirma,
bezeichnungen: [...rollen.map((r) => ROLE_LABEL[r]), ...eigene.map((e) => e.name)],
darf,
pruefe: (permission) => {
if (!darf(permission)) throw new Error(`Keine Berechtigung für „${PERMISSION_INFO[permission].label}“.`);
},
};
}
export function assertCanInProject(
roles: Role[],
permission: Permission,
optionen: ProjektOptionen,
) {
if (!canInProject(roles, permission, optionen)) {
throw new Error(`Keine Berechtigung für "${permission}".`);
}
}
/** Voreinstellung, wenn keine Projektschalter im Spiel sind (z. B. Verwaltung). */
export const OPTIONEN_OFFEN: ProjektOptionen = {
anSichtVertrag: true,
anSichtRechnungen: true,
anSichtBautagebuch: true,
anSichtDokumentation: true,
anDarfDokumente: true,
};
+47
View File
@@ -0,0 +1,47 @@
import { prisma } from "./db";
import { ROLE_LABEL, ROLE_HINT, ROLE_SEITE } from "./rbac";
import type { Role } from "@prisma/client";
export type RollenOption = {
wert: string; // Standardrolle oder "custom:<id>"
label: string;
hinweis: string;
eigen: boolean;
seite: string;
};
/** Alle vergebbaren Rollen: die elf Standardrollen plus die aktiven eigenen. */
export async function rollenAuswahl(): Promise<RollenOption[]> {
const eigene = await prisma.customRole.findMany({
where: { aktiv: true },
orderBy: { name: "asc" },
});
const standard = (Object.keys(ROLE_LABEL) as Role[])
.filter((r) => r !== "ADMIN") // Administration wird am Zugang vergeben, nicht im Projekt
.map((r) => ({
wert: r,
label: ROLE_LABEL[r],
hinweis: ROLE_HINT[r],
eigen: false,
seite: ROLE_SEITE[r] as string,
}));
return [
...standard,
...eigene.map((e) => ({
wert: `custom:${e.id}`,
label: e.name,
hinweis: e.beschreibung ?? `Eigene Rolle mit ${e.rechte.length} Rechten.`,
eigen: true,
seite: e.seite as string,
})),
];
}
/** Zerlegt einen Auswahlwert in Standardrolle oder eigene Rolle. */
export function zerlegeRollenwert(wert: string): { role: Role | null; customRoleId: string | null } {
return wert.startsWith("custom:")
? { role: null, customRoleId: wert.slice(7) }
: { role: wert as Role, customRoleId: null };
}