diff --git a/README.md b/README.md index 8101c34..74175d6 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index a418f47..bf8dc6b 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -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]) } // ═══════════════════════════════════════════════════════════════════ diff --git a/app/src/app/(app)/profil/page.tsx b/app/src/app/(app)/profil/page.tsx index 0789cea..fe4d077 100644 --- a/app/src/app/(app)/profil/page.tsx +++ b/app/src/app/(app)/profil/page.tsx @@ -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({
{m.project.name}
{m.project.nummer}
- {ROLE_LABEL[m.role]} + {rollenBezeichnung(m)} ))} diff --git a/app/src/app/(app)/projekte/[id]/bautagebuch/actions.ts b/app/src/app/(app)/projekte/[id]/bautagebuch/actions.ts index b23a433..3342459 100644 --- a/app/src/app/(app)/projekte/[id]/bautagebuch/actions.ts +++ b/app/src/app/(app)/projekte/[id]/bautagebuch/actions.ts @@ -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({ diff --git a/app/src/app/(app)/projekte/[id]/bautagebuch/page.tsx b/app/src/app/(app)/projekte/[id]/bautagebuch/page.tsx index ff0a78f..a8b7f78 100644 --- a/app/src/app/(app)/projekte/[id]/bautagebuch/page.tsx +++ b/app/src/app/(app)/projekte/[id]/bautagebuch/page.tsx @@ -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 ; - const darfSchreiben = can(roles, "bautagebuch.schreiben"); + const darfSchreiben = rechte.darf("bautagebuch.schreiben"); const eintraege = await prisma.siteDiaryEntry.findMany({ where: { projectId: id }, diff --git a/app/src/app/(app)/projekte/[id]/beteiligte/page.tsx b/app/src/app/(app)/projekte/[id]/beteiligte/page.tsx index 109796a..1ae3e69 100644 --- a/app/src/app/(app)/projekte/[id]/beteiligte/page.tsx +++ b/app/src/app/(app)/projekte/[id]/beteiligte/page.tsx @@ -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 (
@@ -70,8 +71,15 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
{m.user.position}
- {ROLE_LABEL[m.role]} -
{ROLE_HINT[m.role]}
+ {rollenBezeichnung(m)} + {m.customRole && ( + + eigene Rolle + + )} +
+ {m.role ? ROLE_HINT[m.role] : (m.customRole?.beschreibung ?? "Selbst angelegte Rolle.")} +
{m.fachbereiche.length ? m.fachbereiche.join(", ") : alle} @@ -119,6 +127,23 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str ))} ))} + {eigeneImProjekt.map((e) => ( + + + {e.name} + eigen + + {RECHTE_ANZEIGE.map((r) => ( + + {e.rechte.includes(r.schluessel) ? ( + + ) : ( + + )} + + ))} + + ))}
diff --git a/app/src/app/(app)/projekte/[id]/dokumentation/actions.ts b/app/src/app/(app)/projekte/[id]/dokumentation/actions.ts index b835450..900db02 100644 --- a/app/src/app/(app)/projekte/[id]/dokumentation/actions.ts +++ b/app/src/app/(app)/projekte/[id]/dokumentation/actions.ts @@ -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 = {}; if (status) { diff --git a/app/src/app/(app)/projekte/[id]/dokumentation/page.tsx b/app/src/app/(app)/projekte/[id]/dokumentation/page.tsx index 8a456bb..0fd761a 100644 --- a/app/src/app/(app)/projekte/[id]/dokumentation/page.tsx +++ b/app/src/app/(app)/projekte/[id]/dokumentation/page.tsx @@ -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 ; - const darfSteuern = can(roles, "ablage.steuern"); + const darfSteuern = rechte.darf("ablage.steuern"); const akte = sp.akte ?? "P"; const alle = await prisma.projectDocumentSlot.findMany({ diff --git a/app/src/app/(app)/projekte/[id]/dokumente/actions.ts b/app/src/app/(app)/projekte/[id]/dokumente/actions.ts index 609dd9d..294d8a3 100644 --- a/app/src/app/(app)/projekte/[id]/dokumente/actions.ts +++ b/app/src/app/(app)/projekte/[id]/dokumente/actions.ts @@ -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"); -} diff --git a/app/src/app/(app)/projekte/[id]/dokumente/page.tsx b/app/src/app/(app)/projekte/[id]/dokumente/page.tsx index 858471f..796d29e 100644 --- a/app/src/app/(app)/projekte/[id]/dokumente/page.tsx +++ b/app/src/app/(app)/projekte/[id]/dokumente/page.tsx @@ -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({ diff --git a/app/src/app/(app)/projekte/[id]/einstellungen/Mitglieder.tsx b/app/src/app/(app)/projekte/[id]/einstellungen/Mitglieder.tsx index 87dda30..3fdf2bf 100644 --- a/app/src/app/(app)/projekte/[id]/einstellungen/Mitglieder.tsx +++ b/app/src/app/(app)/projekte/[id]/einstellungen/Mitglieder.tsx @@ -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) => ( ))} @@ -145,6 +147,9 @@ export function Mitglieder({ {m.roleLabel} + {m.eigen && ( + eigen + )} {m.fachbereiche.length ? m.fachbereiche.join(", ") : alle} diff --git a/app/src/app/(app)/projekte/[id]/einstellungen/actions.ts b/app/src/app/(app)/projekte/[id]/einstellungen/actions.ts index 90c0811..e10717c 100644 --- a/app/src/app/(app)/projekte/[id]/einstellungen/actions.ts +++ b/app/src/app/(app)/projekte/[id]/einstellungen/actions.ts @@ -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:" 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") { diff --git a/app/src/app/(app)/projekte/[id]/einstellungen/page.tsx b/app/src/app/(app)/projekte/[id]/einstellungen/page.tsx index 2647ec5..890732d 100644 --- a/app/src/app/(app)/projekte/[id]/einstellungen/page.tsx +++ b/app/src/app/(app)/projekte/[id]/einstellungen/page.tsx @@ -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 ; + const { rechte, projekt } = await projektKontext(id); + if (!rechte.darf("projekt.bearbeiten")) return ; 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") && ( ({ @@ -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} /> )} diff --git a/app/src/app/(app)/projekte/[id]/kommunikation/actions.ts b/app/src/app/(app)/projekte/[id]/kommunikation/actions.ts index 9147537..ad4f70f 100644 --- a/app/src/app/(app)/projekte/[id]/kommunikation/actions.ts +++ b/app/src/app/(app)/projekte/[id]/kommunikation/actions.ts @@ -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); diff --git a/app/src/app/(app)/projekte/[id]/kommunikation/page.tsx b/app/src/app/(app)/projekte/[id]/kommunikation/page.tsx index 860faeb..afdf42d 100644 --- a/app/src/app/(app)/projekte/[id]/kommunikation/page.tsx +++ b/app/src/app/(app)/projekte/[id]/kommunikation/page.tsx @@ -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 (
- {can(roles, "chat.schreiben") && } + {rechte.darf("chat.schreiben") && } ) : (

Für dieses Projekt ist noch kein Kanal angelegt.

diff --git a/app/src/app/(app)/projekte/[id]/layout.tsx b/app/src/app/(app)/projekte/[id]/layout.tsx index eb111f5..e0d32f2 100644 --- a/app/src/app/(app)/projekte/[id]/layout.tsx +++ b/app/src/app/(app)/projekte/[id]/layout.tsx @@ -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[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 ( diff --git a/app/src/app/(app)/projekte/[id]/meldungen/actions.ts b/app/src/app/(app)/projekte/[id]/meldungen/actions.ts index fcf4156..3b6a8c8 100644 --- a/app/src/app/(app)/projekte/[id]/meldungen/actions.ts +++ b/app/src/app/(app)/projekte/[id]/meldungen/actions.ts @@ -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 }, diff --git a/app/src/app/(app)/projekte/[id]/meldungen/page.tsx b/app/src/app/(app)/projekte/[id]/meldungen/page.tsx index c8f737b..3bf3859 100644 --- a/app/src/app/(app)/projekte/[id]/meldungen/page.tsx +++ b/app/src/app/(app)/projekte/[id]/meldungen/page.tsx @@ -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({ ))}
- {can(roles, "vorgang.anlegen") && ( + {rechte.darf("vorgang.anlegen") && ( )}
@@ -149,7 +148,7 @@ export default async function Meldungen({
- {can(roles, "vorgang.bearbeiten") ? ( + {rechte.darf("vorgang.bearbeiten") ? ( ) : ( diff --git a/app/src/app/(app)/projekte/[id]/page.tsx b/app/src/app/(app)/projekte/[id]/page.tsx index 8fa56c3..331032f 100644 --- a/app/src/app/(app)/projekte/[id]/page.tsx +++ b/app/src/app/(app)/projekte/[id]/page.tsx @@ -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[1]) => canInProject(roles, p, optionen); const zeigeVertraege = darf("vertrag.lesen"); const zeigeDokumentation = darf("ablage.lesen"); const zeigeTagebuch = darf("bautagebuch.lesen"); diff --git a/app/src/app/(app)/projekte/[id]/rechnungen/[rid]/page.tsx b/app/src/app/(app)/projekte/[id]/rechnungen/[rid]/page.tsx index d3749dc..5375201 100644 --- a/app/src/app/(app)/projekte/[id]/rechnungen/[rid]/page.tsx +++ b/app/src/app/(app)/projekte/[id]/rechnungen/[rid]/page.tsx @@ -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 ; 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)}
)} - {!b.erledigt && can(roles, "rechnung.sachlich") && } + {!b.erledigt && rechte.darf("rechnung.sachlich") && } diff --git a/app/src/app/(app)/projekte/[id]/rechnungen/actions.ts b/app/src/app/(app)/projekte/[id]/rechnungen/actions.ts index bedcc21..124f39e 100644 --- a/app/src/app/(app)/projekte/[id]/rechnungen/actions.ts +++ b/app/src/app/(app)/projekte/[id]/rechnungen/actions.ts @@ -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 }, diff --git a/app/src/app/(app)/projekte/[id]/rechnungen/page.tsx b/app/src/app/(app)/projekte/[id]/rechnungen/page.tsx index 35e4add..599b19b 100644 --- a/app/src/app/(app)/projekte/[id]/rechnungen/page.tsx +++ b/app/src/app/(app)/projekte/[id]/rechnungen/page.tsx @@ -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 ; const rechnungen = await prisma.invoice.findMany({ diff --git a/app/src/app/(app)/projekte/[id]/stunden/actions.ts b/app/src/app/(app)/projekte/[id]/stunden/actions.ts index dd8cb90..20e19c1 100644 --- a/app/src/app/(app)/projekte/[id]/stunden/actions.ts +++ b/app/src/app/(app)/projekte/[id]/stunden/actions.ts @@ -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 } }); diff --git a/app/src/app/(app)/projekte/[id]/stunden/page.tsx b/app/src/app/(app)/projekte/[id]/stunden/page.tsx index 3cb6b50..054f3e0 100644 --- a/app/src/app/(app)/projekte/[id]/stunden/page.tsx +++ b/app/src/app/(app)/projekte/[id]/stunden/page.tsx @@ -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 = { 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
{a.bemerkung &&

{a.bemerkung}

} - {can(roles, "aufmass.pruefen") && a.status === "EINGEREICHT" ? ( + {rechte.darf("aufmass.pruefen") && a.status === "EINGEREICHT" ? ( ({ diff --git a/app/src/app/(app)/projekte/[id]/vertraege/page.tsx b/app/src/app/(app)/projekte/[id]/vertraege/page.tsx index 1b393b2..ffd1ba8 100644 --- a/app/src/app/(app)/projekte/[id]/vertraege/page.tsx +++ b/app/src/app/(app)/projekte/[id]/vertraege/page.tsx @@ -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 ; const vertraege = await prisma.contract.findMany({ diff --git a/app/src/app/(app)/projekte/neu/ProjektFormular.tsx b/app/src/app/(app)/projekte/neu/ProjektFormular.tsx index c70a271..0f38bbe 100644 --- a/app/src/app/(app)/projekte/neu/ProjektFormular.tsx +++ b/app/src/app/(app)/projekte/neu/ProjektFormular.tsx @@ -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) => ( ))} diff --git a/app/src/app/(app)/projekte/neu/actions.ts b/app/src/app/(app)/projekte/neu/actions.ts index 68bd834..1fb0644 100644 --- a/app/src/app/(app)/projekte/neu/actions.ts +++ b/app/src/app/(app)/projekte/neu/actions.ts @@ -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."); } diff --git a/app/src/app/(app)/projekte/neu/page.tsx b/app/src/app/(app)/projekte/neu/page.tsx index 9204858..a510bdf 100644 --- a/app/src/app/(app)/projekte/neu/page.tsx +++ b/app/src/app/(app)/projekte/neu/page.tsx @@ -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} diff --git a/app/src/app/(app)/projekte/page.tsx b/app/src/app/(app)/projekte/page.tsx index 0d36771..a7a1bab 100644 --- a/app/src/app/(app)/projekte/page.tsx +++ b/app/src/app/(app)/projekte/page.tsx @@ -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(); for (const s of slotStat) { @@ -120,7 +117,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
- {p.members.map((m) => ROLE_LABEL[m.role]).join(", ") || "Administration"} + {p.members.map((m) => rollenBezeichnung(m)).join(", ") || "Administration"} IBN {datum(p.inbetriebnahme)}
diff --git a/app/src/app/(app)/uebersicht/page.tsx b/app/src/app/(app)/uebersicht/page.tsx index 01c0188..c3aa53e 100644 --- a/app/src/app/(app)/uebersicht/page.tsx +++ b/app/src/app/(app)/uebersicht/page.tsx @@ -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 ; } - // Projekte trennen: in welchen ist der Nutzer auf der Bauherrenseite, - // in welchen Auftragnehmer? Für Letztere gilt überall die Firmenschranke. - const rollenJeProjekt = new Map(); + // 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(); 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 (

Guten Tag, {user.name.split(" ")[0]}.

- {rollen.map((r) => ROLE_LABEL[r]).join(" · ")} · {projektIds.length}{" "} + {rollen.join(" · ")} · {projektIds.length}{" "} {projektIds.length === 1 ? "Projekt" : "Projekte"}

diff --git a/app/src/app/(app)/verwaltung/page.tsx b/app/src/app/(app)/verwaltung/page.tsx index caf3986..c55647a 100644 --- a/app/src/app/(app)/verwaltung/page.tsx +++ b/app/src/app/(app)/verwaltung/page.tsx @@ -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 }))} diff --git a/app/src/app/(app)/verwaltung/rollen/Rolleneditor.tsx b/app/src/app/(app)/verwaltung/rollen/Rolleneditor.tsx new file mode 100644 index 0000000..6d092e1 --- /dev/null +++ b/app/src/app/(app)/verwaltung/rollen/Rolleneditor.tsx @@ -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); + const [fehler, setFehler] = useState(null); + const [pending, start] = useTransition(); + + const lauf = (fn: () => Promise) => + start(async () => { + setFehler(null); + try { + await fn(); + setModus(null); + } catch (e) { + setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich."); + } + }); + + return ( +
+
+
+

Eigene Rollen

+

+ 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. +

+
+ +
+ + {fehler && ( +

{fehler}

+ )} + + {modus?.art === "neu" && ( + setModus(null)} + onSpeichern={(fd) => lauf(() => rolleSpeichern(fd))} + /> + )} + + {eigene.length === 0 && modus?.art !== "neu" ? ( +

+ 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. +

+ ) : ( + + + + + + + + + + + + + {eigene.map((r) => ( + + + + + + + + + + + {modus?.art === "bearbeiten" && modus.rolle.id === r.id && ( + + + + )} + + ))} + +
RolleSeiteVorlageRechtevergebenAktion
+
+ {r.name} + {!r.aktiv && ( + stillgelegt + )} +
+ {r.beschreibung && ( +
{r.beschreibung}
+ )} +
+ + {r.vorlageLabel ?? "von Grund auf"}{r.rechte.length}{r.vergeben}× +
+ + + +
+
+ setModus(null)} + onSpeichern={(fd) => lauf(() => rolleSpeichern(fd))} + /> +
+ )} +
+ ); +} + +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 {text}; +} + +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(rolle?.rechte ?? []); + const [vorlage, setVorlage] = useState(rolle?.vorlage ?? ""); + const [seite, setSeite] = useState(rolle?.seite ?? "NEUTRAL"); + const [vorlageUebernommen, setVorlageUebernommen] = useState(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 ( +
+ {rolle && } + +
+
+ + +
+ + {!rolle && ( +
+ + +

+ Übernimmt die Rechte der Standardrolle als Ausgangspunkt. +

+
+ )} + +
+ + +

+ {SEITEN.find(([k]) => k === seite)?.[2]} +

+
+ +
+ + +
+
+ + {vorlageUebernommen && ( +

+ + Rechte und Seite von „{vorlageUebernommen}“ übernommen – jetzt anpassen. +

+ )} + + {seite === "AN" && ( +

+ + 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. +

+ )} + +
+
+ Rechte ({gewaehlt.length} gewählt) +
+ + +
+
+ +
+ {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 ( +
+
+ {g} + {freiInGruppe.length > 0 && ( + + )} +
+
+ {inGruppe.map((r) => + r.gesperrt ? ( +
+ + {r.label} +
+ ) : ( + + ), + )} +
+
+ ); + })} +
+
+ +
+ + + {gewaehlt.length === 0 && ( + Mindestens ein Recht auswählen. + )} +
+
+ ); +} diff --git a/app/src/app/(app)/verwaltung/rollen/actions.ts b/app/src/app/(app)/verwaltung/rollen/actions.ts new file mode 100644 index 0000000..8d49354 --- /dev/null +++ b/app/src/app/(app)/verwaltung/rollen/actions.ts @@ -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"); +} diff --git a/app/src/app/(app)/verwaltung/rollen/page.tsx b/app/src/app/(app)/verwaltung/rollen/page.tsx index a7c2d47..56a1f5a 100644 --- a/app/src/app/(app)/verwaltung/rollen/page.tsx +++ b/app/src/app/(app)/verwaltung/rollen/page.tsx @@ -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() {

- {/* ── Rollen ───────────────────────────────────────────── */} + {/* ── Rolleneditor ─────────────────────────────────────── */} + ({ + 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() {
{SEITE_TEXT[seite].titel} + + Standardrollen · unveränderlich + {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]} ))} + {eigeneRollen.filter((e) => e.aktiv).map((e) => ( + + {e.name.slice(0, 6)}… + + ))} @@ -109,7 +155,7 @@ export default async function Rollenlegende() { return ( - + e.aktiv).length + 1}> {gruppe} @@ -140,6 +186,21 @@ export default async function Rollenlegende() { ); })} + {eigeneRollen.filter((e) => e.aktiv).map((e) => { + const erlaubt = e.rechte.includes(p); + const optional = !erlaubt && e.seite !== "AG" && !!info.optional; + return ( + + {erlaubt ? ( + + ) : optional ? ( + + ) : ( + · + )} + + ); + })} ); })} @@ -206,6 +267,8 @@ export default async function Rollenlegende() {
  • · Auftragnehmer sehen ausschließlich Daten der eigenen Firma.
  • · Wer nicht im Projekt eingetragen ist, sieht das Projekt nicht – auch nicht in der Liste.
  • · Es muss stets mindestens ein aktiver Administrationszugang bestehen.
  • +
  • · Die elf Standardrollen sind Teil des Programms und weder änderbar noch löschbar.
  • +
  • · Administrationsrechte lassen sich nicht über eine eigene Rolle vergeben.
  • diff --git a/app/src/app/api/dateien/[versionId]/route.ts b/app/src/app/api/dateien/[versionId]/route.ts index b36f185..c41ab75 100644 --- a/app/src/app/api/dateien/[versionId]/route.ts +++ b/app/src/app/api/dateien/[versionId]/route.ts @@ -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; diff --git a/app/src/app/api/projekte/[id]/dokumentation.csv/route.ts b/app/src/app/api/projekte/[id]/dokumentation.csv/route.ts index 4d4c398..1859699 100644 --- a/app/src/app/api/projekte/[id]/dokumentation.csv/route.ts +++ b/app/src/app/api/projekte/[id]/dokumentation.csv/route.ts @@ -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"); diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts index 042521f..f4f2f4c 100644 --- a/app/src/lib/auth.ts +++ b/app/src/lib/auth.ts @@ -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 { + 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 => !!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 { + 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")), + ); +} diff --git a/app/src/lib/rbac.ts b/app/src/lib/rbac.ts index 0c82219..dc2abbd 100644 --- a/app/src/lib/rbac.ts +++ b/app/src/lib/rbac.ts @@ -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 = { ADMIN: "ADM", PROJEKTLEITER: "PL", @@ -199,22 +208,44 @@ export const ROLE_HINT: Record = { 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 "dokument.hochladen": "anDarfDokumente", }; +// ═══════════════════════════════════════════════════════════════════ +// Rechtelage einer Person in einem Projekt +// ═══════════════════════════════════════════════════════════════════ + /** - * Berechtigung unter Berücksichtigung der Projekteinstellungen. - * Für Rollen der Bauherrenseite identisch mit `can()`. + * Bündelt, was jemand in einem Projekt darf – aus Standardrollen und eigenen + * Rollen zusammengeführt. Einzige Stelle, an der Rechte ausgewertet werden. */ -export function canInProject( - roles: Role[], - permission: Permission, +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, -): boolean { - const extern = isExternalOnly(roles); +): Rechtelage { + const leer = rollen.length === 0 && eigene.length === 0; - if (extern) { - const entzug = ENTZIEHBAR_FUER_EXTERNE[permission]; - if (entzug && !optionen[entzug]) return false; + // 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 gewaehrung = OPTIONAL_FUER_EXTERNE[permission]; - if (gewaehrung && optionen[gewaehrung]) return true; - } + const grundmenge = new Set(); + 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); - return can(roles, permission); + 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] && grundmenge.size > 0) return true; + } + return grundmenge.has(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, +}; diff --git a/app/src/lib/rollenauswahl.ts b/app/src/lib/rollenauswahl.ts new file mode 100644 index 0000000..2588898 --- /dev/null +++ b/app/src/lib/rollenauswahl.ts @@ -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:" + label: string; + hinweis: string; + eigen: boolean; + seite: string; +}; + +/** Alle vergebbaren Rollen: die elf Standardrollen plus die aktiven eigenen. */ +export async function rollenAuswahl(): Promise { + 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 }; +}