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 ### 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 Spalte AN zeigt das Rollenrecht; ein `(○)` bedeutet: greift zusätzlich nur, wenn der
passende Projektschalter gesetzt ist (siehe Abschnitt *Je Projekt einstellbar*). 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 **Diese Schalter steuern ausschließlich die Sichtbarkeit von Bereichen, niemals den
Datenumfang.** Ein Auftragnehmer sieht in jedem Fall nur Verträge, Aufmaße, 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 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 **Seite des Bauvertrags**, die jede Rolle mitbringt (`Rechtelage.nurEigeneFirma` in
aufhebbar; die Datenbankabfragen filtern zusätzlich auf `companyId`. `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, Bewusst nicht geschaltet: Der Reiter **Beteiligte** zeigt allen Projektbeteiligten,
welche Firmen und Personen mitwirken. Das entspricht Position B.I 0 der 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 | | **Benutzer** | Zugänge anlegen, bearbeiten, sperren und entsperren, Kennwort zurücksetzen |
| **Firmen** | Firmenstamm mit Art, Anschrift, Kreditorennummer und USt-IdNr. | | **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 | | **Protokoll** | Alle Vorgänge mit Filter nach Aktion und Person |
**Kennwörter.** Beim Anlegen wird ein Startkennwort erzeugt und **genau einmal** angezeigt **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 *Einstellungen → Beteiligte*. Das Administrationsrecht ist davon unabhängig und wirkt
projektübergreifend entsprechend sparsam vergeben. 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 ### Absichtliche Sperren
Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache: Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache:
+38 -2
View File
@@ -40,6 +40,34 @@ enum Role {
LESER 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 { model Company {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -111,6 +139,7 @@ model User {
auditLogs AuditLog[] auditLogs AuditLog[]
slotResponsible ProjectDocumentSlot[] @relation("SlotResponsible") slotResponsible ProjectDocumentSlot[] @relation("SlotResponsible")
tasks Task[] @relation("TaskAssignee") tasks Task[] @relation("TaskAssignee")
eigeneRollen CustomRole[] @relation("CustomRoleAutor")
@@index([companyId]) @@index([companyId])
} }
@@ -140,13 +169,20 @@ model ProjectMember {
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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, auf die sich diese Rolle beschränkt (leer = alle)
fachbereiche String[] @default([]) fachbereiche String[] @default([])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([projectId, userId, role]) @@unique([projectId, userId, role, customRoleId])
@@index([userId]) @@index([userId])
@@index([customRoleId])
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
+3 -3
View File
@@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireUser, hashPassword, verifyPassword } from "@/lib/auth"; import { requireUser, hashPassword, verifyPassword } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { ROLE_LABEL } from "@/lib/rbac"; import { rollenBezeichnung } from "@/lib/rbac";
import { datumZeit } from "@/lib/format"; import { datumZeit } from "@/lib/format";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,7 +18,7 @@ export default async function Profil({
where: { id: session.id }, where: { id: session.id },
include: { include: {
company: true, 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-slate-200">{m.project.name}</div>
<div className="text-[11px] text-db-muted">{m.project.nummer}</div> <div className="text-[11px] text-db-muted">{m.project.nummer}</div>
</td> </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> </tr>
))} ))}
</tbody> </tbody>
@@ -2,15 +2,14 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { protokolliere } from "@/lib/notify"; import { protokolliere } from "@/lib/notify";
export async function eintragAnlegen(formData: FormData) { export async function eintragAnlegen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "bautagebuch.schreiben"); rechte.pruefe("bautagebuch.schreiben");
const datum = new Date(String(formData.get("datum"))); const datum = new Date(String(formData.get("datum")));
const letzter = await prisma.siteDiaryEntry.findFirst({ const letzter = await prisma.siteDiaryEntry.findFirst({
@@ -52,8 +51,8 @@ export async function eintragAnlegen(formData: FormData) {
export async function eintragAbschliessen(eintragId: string) { export async function eintragAbschliessen(eintragId: string) {
const user = await requireUser(); const user = await requireUser();
const eintrag = await prisma.siteDiaryEntry.findUniqueOrThrow({ where: { id: eintragId } }); const eintrag = await prisma.siteDiaryEntry.findUniqueOrThrow({ where: { id: eintragId } });
const roles = await rolesInProject(user.id, eintrag.projectId); const rechte = await rechteImProjekt(user.id, eintrag.projectId);
assertCan(roles, "bautagebuch.schreiben"); rechte.pruefe("bautagebuch.schreiben");
if (eintrag.gesperrtAm) throw new Error("Der Eintrag ist bereits gesperrt."); if (eintrag.gesperrtAm) throw new Error("Der Eintrag ist bereits gesperrt.");
await prisma.siteDiaryEntry.update({ await prisma.siteDiaryEntry.update({
@@ -1,7 +1,6 @@
import { CloudSun, Lock, PenLine, Users } from "lucide-react"; import { CloudSun, Lock, PenLine, Users } from "lucide-react";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Leer, Feld, KeinZugriff } from "@/components/ui"; import { StatusChip, Leer, Feld, KeinZugriff } from "@/components/ui";
import { datum, zahl } from "@/lib/format"; import { datum, zahl } from "@/lib/format";
import { NeuerEintrag } from "./NeuerEintrag"; import { NeuerEintrag } from "./NeuerEintrag";
@@ -11,11 +10,11 @@ export const dynamic = "force-dynamic";
export default async function Bautagebuch({ params }: { params: Promise<{ id: string }> }) { export default async function Bautagebuch({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { user, roles, optionen } = await projektKontext(id); const { user, rechte } = await projektKontext(id);
if (!canInProject(roles, "bautagebuch.lesen", optionen)) if (!rechte.darf("bautagebuch.lesen"))
return <KeinZugriff bereich="Das Bautagebuch" />; return <KeinZugriff bereich="Das Bautagebuch" />;
const darfSchreiben = can(roles, "bautagebuch.schreiben"); const darfSchreiben = rechte.darf("bautagebuch.schreiben");
const eintraege = await prisma.siteDiaryEntry.findMany({ const eintraege = await prisma.siteDiaryEntry.findMany({
where: { projectId: id }, where: { projectId: id },
@@ -1,6 +1,6 @@
import { requireProjectAccess } from "@/lib/auth"; import { requireProjectAccess } from "@/lib/auth";
import { prisma } from "@/lib/db"; 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 { datum } from "@/lib/format";
import type { Role } from "@prisma/client"; 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({ const mitglieder = await prisma.projectMember.findMany({
where: { projectId: id }, where: { projectId: id },
include: { user: { include: { company: true } } }, include: { user: { include: { company: true } }, customRole: true },
orderBy: [{ role: "asc" }], orderBy: [{ role: "asc" }],
}); });
@@ -33,7 +33,8 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
nachFirma.get(k)!.push(m); 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 ( return (
<div className="space-y-5"> <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> <div className="text-[11px] text-db-muted">{m.user.position}</div>
</td> </td>
<td className="td"> <td className="td">
<span className="chip border-db-line bg-db-dark text-slate-300">{ROLE_LABEL[m.role]}</span> <span className="chip border-db-line bg-db-dark text-slate-300">{rollenBezeichnung(m)}</span>
<div className="mt-0.5 text-[11px] text-db-muted">{ROLE_HINT[m.role]}</div> {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>
<td className="td text-xs text-slate-400"> <td className="td text-xs text-slate-400">
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>} {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> </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> </tbody>
</table> </table>
</div> </div>
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { protokolliere, benachrichtige } from "@/lib/notify"; import { protokolliere, benachrichtige } from "@/lib/notify";
import type { SlotStatus } from "@prisma/client"; import type { SlotStatus } from "@prisma/client";
@@ -17,8 +16,8 @@ export async function slotAendern(formData: FormData) {
where: { id: slotId }, where: { id: slotId },
include: { requirement: { select: { titel: true } } }, include: { requirement: { select: { titel: true } } },
}); });
const roles = await rolesInProject(user.id, slot.projectId); const rechte = await rechteImProjekt(user.id, slot.projectId);
assertCan(roles, "ablage.steuern"); rechte.pruefe("ablage.steuern");
const daten: Record<string, unknown> = {}; const daten: Record<string, unknown> = {};
if (status) { if (status) {
@@ -1,7 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Fortschritt, AmpelPunkt, Kachel, KeinZugriff } from "@/components/ui"; import { StatusChip, Fortschritt, AmpelPunkt, Kachel, KeinZugriff } from "@/components/ui";
import { datum, tageBis } from "@/lib/format"; import { datum, tageBis } from "@/lib/format";
import { fristAmpel, UEBERGABE_LABEL, berechneAufbewahrung } from "@/lib/fristen"; import { fristAmpel, UEBERGABE_LABEL, berechneAufbewahrung } from "@/lib/fristen";
@@ -25,11 +24,11 @@ export default async function Dokumentation({
}) { }) {
const { id } = await params; const { id } = await params;
const sp = await searchParams; const sp = await searchParams;
const { roles, projekt, optionen } = await projektKontext(id); const { rechte, projekt } = await projektKontext(id);
if (!canInProject(roles, "ablage.lesen", optionen)) if (!rechte.darf("ablage.lesen"))
return <KeinZugriff bereich="Der Dokumentationsstand" />; return <KeinZugriff bereich="Der Dokumentationsstand" />;
const darfSteuern = can(roles, "ablage.steuern"); const darfSteuern = rechte.darf("ablage.steuern");
const akte = sp.akte ?? "P"; const akte = sp.akte ?? "P";
const alle = await prisma.projectDocumentSlot.findMany({ const alle = await prisma.projectDocumentSlot.findMany({
@@ -2,9 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan, assertCanInProject, can } from "@/lib/rbac";
import { optionenAus } from "@/lib/projekte";
import { speichereDatei, maxUploadBytes, mimeErlaubt } from "@/lib/storage"; import { speichereDatei, maxUploadBytes, mimeErlaubt } from "@/lib/storage";
import { protokolliere, benachrichtige } from "@/lib/notify"; import { protokolliere, benachrichtige } from "@/lib/notify";
import type { DocumentStatus, Vertraulichkeit } from "@prisma/client"; import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
@@ -12,10 +10,10 @@ import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
export async function dokumentHochladen(formData: FormData) { export async function dokumentHochladen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); 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 } }); 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; const datei = formData.get("datei") as File | null;
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt."); if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
if (datei.size > maxUploadBytes()) if (datei.size > maxUploadBytes())
@@ -107,9 +105,9 @@ export async function neueVersion(formData: FormData) {
where: { id: documentId }, where: { id: documentId },
include: { project: { select: { nummer: true } }, versions: { orderBy: { version: "desc" }, take: 1 } }, 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 } }); 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; const datei = formData.get("datei") as File | null;
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt."); 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 }, where: { id: documentId },
select: { projectId: true, titel: true, slotId: true, createdById: true }, select: { projectId: true, titel: true, slotId: true, createdById: true },
}); });
const roles = await rolesInProject(user.id, dokument.projectId); const rechte = await rechteImProjekt(user.id, dokument.projectId);
assertCan(roles, "dokument.freigeben"); rechte.pruefe("dokument.freigeben");
await prisma.document.update({ where: { id: documentId }, data: { status } }); await prisma.document.update({ where: { id: documentId }, data: { status } });
await prisma.documentReview.create({ await prisma.documentReview.create({
@@ -189,7 +187,3 @@ export async function dokumentStatus(formData: FormData) {
revalidatePath(`/projekte/${dokument.projectId}/dokumente`); revalidatePath(`/projekte/${dokument.projectId}/dokumente`);
revalidatePath(`/projekte/${dokument.projectId}/dokumentation`); 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 { Download, FileText, Upload, ShieldAlert } from "lucide-react";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Leer } from "@/components/ui"; import { StatusChip, Leer } from "@/components/ui";
import { datum, datumZeit } from "@/lib/format"; import { datum, datumZeit } from "@/lib/format";
import { UploadPanel } from "./UploadPanel"; import { UploadPanel } from "./UploadPanel";
@@ -21,10 +20,10 @@ export default async function Dokumente({
}) { }) {
const { id } = await params; const { id } = await params;
const sp = await searchParams; 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 darfHochladen = rechte.darf("dokument.hochladen");
const darfFreigeben = can(roles, "dokument.freigeben"); const darfFreigeben = rechte.darf("dokument.freigeben");
const nurExtern = nurEigeneFirma; const nurExtern = nurEigeneFirma;
const dokumente = await prisma.document.findMany({ const dokumente = await prisma.document.findMany({
@@ -12,6 +12,7 @@ type Mitglied = {
firma: string | null; firma: string | null;
role: string; role: string;
roleLabel: string; roleLabel: string;
eigen: boolean;
fachbereiche: string[]; fachbereiche: string[];
}; };
@@ -25,7 +26,7 @@ export function Mitglieder({
projectId: string; projectId: string;
mitglieder: Mitglied[]; mitglieder: Mitglied[];
personen: { id: string; name: string; position: string | null; firma: string | null }[]; 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[]; fachbereicheAuswahl: string[];
}) { }) {
const [offen, setOffen] = useState(false); const [offen, setOffen] = useState(false);
@@ -87,6 +88,7 @@ export function Mitglieder({
{rollen.map((r) => ( {rollen.map((r) => (
<option key={r.wert} value={r.wert}> <option key={r.wert} value={r.wert}>
{r.label} {r.label}
{r.eigen ? " (eigene Rolle)" : ""}
</option> </option>
))} ))}
</select> </select>
@@ -145,6 +147,9 @@ export function Mitglieder({
</td> </td>
<td className="td"> <td className="td">
<span className="chip border-db-line bg-db-dark text-slate-300">{m.roleLabel}</span> <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>
<td className="td text-xs text-slate-400"> <td className="td text-xs text-slate-400">
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>} {m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { slotsErzeugen, fristenNeuBerechnen, meilensteineAusTerminen } from "@/lib/projekte"; import { slotsErzeugen, fristenNeuBerechnen, meilensteineAusTerminen } from "@/lib/projekte";
import { benachrichtige, protokolliere } from "@/lib/notify"; import { benachrichtige, protokolliere } from "@/lib/notify";
import type { ProjectStatus, Role } from "@prisma/client"; import type { ProjectStatus, Role } from "@prisma/client";
@@ -17,8 +16,8 @@ function datumOderNull(v: FormDataEntryValue | null): Date | null {
export async function einstellungenSpeichern(formData: FormData) { export async function einstellungenSpeichern(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "projekt.bearbeiten"); rechte.pruefe("projekt.bearbeiten");
const vorher = await prisma.project.findUniqueOrThrow({ where: { id: projectId } }); const vorher = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
const neu = { const neu = {
@@ -48,8 +47,8 @@ export async function einstellungenSpeichern(formData: FormData) {
export async function projektdatenSpeichern(formData: FormData) { export async function projektdatenSpeichern(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "projekt.bearbeiten"); rechte.pruefe("projekt.bearbeiten");
const termine = { const termine = {
startgespraech: datumOderNull(formData.get("startgespraech")), startgespraech: datumOderNull(formData.get("startgespraech")),
@@ -97,19 +96,28 @@ export async function projektdatenSpeichern(formData: FormData) {
export async function mitgliedHinzufuegen(formData: FormData) { export async function mitgliedHinzufuegen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "projekt.mitglieder"); rechte.pruefe("projekt.mitglieder");
const userId = String(formData.get("userId")); 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); const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
if (!userId) throw new Error("Bitte eine Person auswählen."); if (!userId) throw new Error("Bitte eine Person auswählen.");
if (!auswahl) throw new Error("Bitte eine Rolle auswählen.");
await prisma.projectMember.upsert({ // Eigene Rollen kommen als "custom:<id>" aus dem Formular.
where: { projectId_userId_role: { projectId, userId, role } }, const eigen = auswahl.startsWith("custom:");
create: { projectId, userId, role, fachbereiche }, const role = eigen ? null : (auswahl as Role);
update: { fachbereiche }, 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 // In den allgemeinen Projektkanal aufnehmen
const kanal = await prisma.channel.findFirst({ where: { projectId, kind: "PROJEKT" } }); 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 } }); const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
await protokolliere({ await protokolliere({
userId: user.id, aktion: "MITGLIED_HINZU", entitaet: "ProjectMember", userId: user.id, aktion: "MITGLIED_HINZU", entitaet: "ProjectMember",
entitaetId: userId, projectId, details: { role }, entitaetId: userId, projectId, details: { role: auswahl },
}); });
await benachrichtige({ await benachrichtige({
userIds: [userId], userIds: [userId],
@@ -145,8 +153,8 @@ export async function mitgliedEntfernen(formData: FormData) {
const memberId = String(formData.get("memberId")); const memberId = String(formData.get("memberId"));
const mitglied = await prisma.projectMember.findUniqueOrThrow({ where: { id: memberId } }); const mitglied = await prisma.projectMember.findUniqueOrThrow({ where: { id: memberId } });
const roles = await rolesInProject(user.id, mitglied.projectId); const rechte = await rechteImProjekt(user.id, mitglied.projectId);
assertCan(roles, "projekt.mitglieder"); rechte.pruefe("projekt.mitglieder");
// Die letzte Projektleitung darf nicht entfernt werden // Die letzte Projektleitung darf nicht entfernt werden
if (mitglied.role === "PROJEKTLEITER") { if (mitglied.role === "PROJEKTLEITER") {
@@ -1,25 +1,28 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; 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 { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
import { KeinZugriff } from "@/components/ui"; import { KeinZugriff } from "@/components/ui";
import { EinstellungenFormular } from "./EinstellungenFormular"; import { EinstellungenFormular } from "./EinstellungenFormular";
import { ProjektdatenFormular } from "./ProjektdatenFormular"; import { ProjektdatenFormular } from "./ProjektdatenFormular";
import { Mitglieder } from "./Mitglieder"; import { Mitglieder } from "./Mitglieder";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Einstellungen({ params }: { params: Promise<{ id: string }> }) { export default async function Einstellungen({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { roles, projekt } = await projektKontext(id); const { rechte, projekt } = await projektKontext(id);
if (!can(roles, "projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />; if (!rechte.darf("projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />;
const [mitglieder, personen, slotStat] = await Promise.all([ const [mitglieder, personen, slotStat] = await Promise.all([
prisma.projectMember.findMany({ prisma.projectMember.findMany({
where: { projectId: id }, 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" }], orderBy: [{ role: "asc" }],
}), }),
prisma.user.findMany({ prisma.user.findMany({
@@ -30,6 +33,8 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
prisma.projectDocumentSlot.count({ where: { projectId: id } }), prisma.projectDocumentSlot.count({ where: { projectId: id } }),
]); ]);
const rollen = await rollenAuswahl();
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF"; const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
return ( return (
@@ -66,7 +71,7 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
}} }}
/> />
{can(roles, "projekt.mitglieder") && ( {rechte.darf("projekt.mitglieder") && (
<Mitglieder <Mitglieder
projectId={id} projectId={id}
mitglieder={mitglieder.map((m) => ({ mitglieder={mitglieder.map((m) => ({
@@ -75,8 +80,9 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
name: m.user.name, name: m.user.name,
position: m.user.position, position: m.user.position,
firma: m.user.company?.shortName ?? m.user.company?.name ?? null, firma: m.user.company?.shortName ?? m.user.company?.name ?? null,
role: m.role, role: m.customRole ? `custom:${m.customRole.id}` : (m.role ?? ""),
roleLabel: ROLE_LABEL[m.role], roleLabel: rollenBezeichnung(m),
eigen: !!m.customRole,
fachbereiche: m.fachbereiche, fachbereiche: m.fachbereiche,
}))} }))}
personen={personen.map((p) => ({ personen={personen.map((p) => ({
@@ -85,11 +91,7 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
position: p.position, position: p.position,
firma: p.company?.shortName ?? p.company?.name ?? null, firma: p.company?.shortName ?? p.company?.name ?? null,
}))} }))}
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({ rollen={rollen}
wert: r,
label: ROLE_LABEL[r],
hinweis: ROLE_HINT[r],
}))}
fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ} fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ}
/> />
)} )}
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { benachrichtige, findeErwaehnungen } from "@/lib/notify"; import { benachrichtige, findeErwaehnungen } from "@/lib/notify";
export async function nachrichtSenden(formData: FormData) { 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."); if (!kanal.projectId) throw new Error("Kanal ohne Projektbezug.");
const roles = await rolesInProject(user.id, kanal.projectId); const rechte = await rechteImProjekt(user.id, kanal.projectId);
assertCan(roles, "chat.schreiben"); rechte.pruefe("chat.schreiben");
if (!kanal.mitglieder.some((m) => m.userId === user.id) && !user.isAdmin) if (!kanal.mitglieder.some((m) => m.userId === user.id) && !user.isAdmin)
throw new Error("Sie sind kein Mitglied dieses Kanals."); 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) { export async function kanalAnlegen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "chat.kanal.anlegen"); rechte.pruefe("chat.kanal.anlegen");
const privat = formData.get("privat") === "on"; const privat = formData.get("privat") === "on";
const mitgliedIds = formData.getAll("mitglieder").map(String); const mitgliedIds = formData.getAll("mitglieder").map(String);
@@ -2,7 +2,6 @@ import Link from "next/link";
import { Hash, Lock, Users } from "lucide-react"; import { Hash, Lock, Users } from "lucide-react";
import { requireProjectAccess } from "@/lib/auth"; import { requireProjectAccess } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can } from "@/lib/rbac";
import { relativ } from "@/lib/format"; import { relativ } from "@/lib/format";
import { Nachrichtenfeld } from "./Nachrichtenfeld"; import { Nachrichtenfeld } from "./Nachrichtenfeld";
import { NeuerKanal } from "./NeuerKanal"; import { NeuerKanal } from "./NeuerKanal";
@@ -19,7 +18,7 @@ export default async function Kommunikation({
}) { }) {
const { id } = await params; const { id } = await params;
const sp = await searchParams; const sp = await searchParams;
const { user, roles } = await requireProjectAccess(id); const { user, rechte } = await requireProjectAccess(id);
const kanaele = await prisma.channel.findMany({ const kanaele = await prisma.channel.findMany({
where: { where: {
@@ -56,7 +55,7 @@ export default async function Kommunikation({
return ( return (
<div className="grid gap-5 lg:grid-cols-[260px_1fr]"> <div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<aside className="space-y-3"> <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"> <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"> <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> </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> <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 { prisma } from "@/lib/db";
import { TabLink } from "@/components/NavLink"; import { TabLink } from "@/components/NavLink";
import { StatusChip } from "@/components/ui"; import { StatusChip } from "@/components/ui";
import { can, canInProject } from "@/lib/rbac";
import { datum } from "@/lib/format"; import { datum } from "@/lib/format";
export default async function ProjektLayout({ export default async function ProjektLayout({
@@ -13,12 +12,12 @@ export default async function ProjektLayout({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; 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 abschnitte = await prisma.projectSection.findMany({ where: { projectId: id }, take: 1 });
const a = abschnitte[0]; const a = abschnitte[0];
const base = `/projekte/${id}`; 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 }[] = [ const tabs: { href: string; label: string; sichtbar: boolean; exact?: boolean }[] = [
{ href: base, label: "Übersicht", sichtbar: true, exact: true }, { 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}/rechnungen`, label: "Rechnungsprüfung", sichtbar: darf("rechnung.lesen") },
{ href: `${base}/kommunikation`, label: "Kommunikation", sichtbar: true }, { href: `${base}/kommunikation`, label: "Kommunikation", sichtbar: true },
{ href: `${base}/beteiligte`, label: "Beteiligte", 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 ( return (
@@ -2,16 +2,15 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { benachrichtige, protokolliere } from "@/lib/notify"; import { benachrichtige, protokolliere } from "@/lib/notify";
import type { IssueStatus, IssueType, Severity } from "@prisma/client"; import type { IssueStatus, IssueType, Severity } from "@prisma/client";
export async function meldungAnlegen(formData: FormData) { export async function meldungAnlegen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "vorgang.anlegen"); rechte.pruefe("vorgang.anlegen");
const letzte = await prisma.issue.findFirst({ const letzte = await prisma.issue.findFirst({
where: { projectId }, where: { projectId },
@@ -68,8 +67,8 @@ export async function meldungAnlegen(formData: FormData) {
export async function meldungStatus(issueId: string, status: IssueStatus, kommentar?: string) { export async function meldungStatus(issueId: string, status: IssueStatus, kommentar?: string) {
const user = await requireUser(); const user = await requireUser();
const vorgang = await prisma.issue.findUniqueOrThrow({ where: { id: issueId } }); const vorgang = await prisma.issue.findUniqueOrThrow({ where: { id: issueId } });
const roles = await rolesInProject(user.id, vorgang.projectId); const rechte = await rechteImProjekt(user.id, vorgang.projectId);
assertCan(roles, "vorgang.bearbeiten"); rechte.pruefe("vorgang.bearbeiten");
await prisma.issue.update({ await prisma.issue.update({
where: { id: issueId }, where: { id: issueId },
@@ -1,6 +1,5 @@
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can } from "@/lib/rbac";
import { StatusChip, Leer, Kachel } from "@/components/ui"; import { StatusChip, Leer, Kachel } from "@/components/ui";
import { datum, eur, tageBis } from "@/lib/format"; import { datum, eur, tageBis } from "@/lib/format";
import { NeueMeldung } from "./NeueMeldung"; import { NeueMeldung } from "./NeueMeldung";
@@ -31,7 +30,7 @@ export default async function Meldungen({
}) { }) {
const { id } = await params; const { id } = await params;
const sp = await searchParams; 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([ const [vorgaenge, firmen, mitglieder, vertraege] = await Promise.all([
prisma.issue.findMany({ prisma.issue.findMany({
@@ -93,7 +92,7 @@ export default async function Meldungen({
</Link> </Link>
))} ))}
<div className="ml-auto"> <div className="ml-auto">
{can(roles, "vorgang.anlegen") && ( {rechte.darf("vorgang.anlegen") && (
<NeueMeldung projectId={id} firmen={firmen} personen={personen} vertraege={vertraege} /> <NeueMeldung projectId={id} firmen={firmen} personen={personen} vertraege={vertraege} />
)} )}
</div> </div>
@@ -149,7 +148,7 @@ export default async function Meldungen({
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{can(roles, "vorgang.bearbeiten") ? ( {rechte.darf("vorgang.bearbeiten") ? (
<StatusWechsel issueId={v.id} status={v.status} /> <StatusWechsel issueId={v.id} status={v.status} />
) : ( ) : (
<StatusChip status={v.status} /> <StatusChip status={v.status} />
+2 -3
View File
@@ -1,7 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canInProject } from "@/lib/rbac";
import { Kachel, StatusChip, Fortschritt, Feld } from "@/components/ui"; import { Kachel, StatusChip, Fortschritt, Feld } from "@/components/ui";
import { datum, eur, tageBis } from "@/lib/format"; import { datum, eur, tageBis } from "@/lib/format";
import { CheckCircle2, Circle, CircleDot } from "lucide-react"; import { CheckCircle2, Circle, CircleDot } from "lucide-react";
@@ -17,9 +16,9 @@ export default async function ProjektUebersicht({
}) { }) {
const { id } = await params; const { id } = await params;
const { angelegt } = await searchParams; 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 zeigeVertraege = darf("vertrag.lesen");
const zeigeDokumentation = darf("ablage.lesen"); const zeigeDokumentation = darf("ablage.lesen");
const zeigeTagebuch = darf("bautagebuch.lesen"); const zeigeTagebuch = darf("bautagebuch.lesen");
@@ -3,7 +3,6 @@ import { notFound } from "next/navigation";
import { AlertTriangle, ArrowLeft, CheckCircle2, Info } from "lucide-react"; import { AlertTriangle, ArrowLeft, CheckCircle2, Info } from "lucide-react";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can, canInProject } from "@/lib/rbac";
import { StatusChip, Feld, Kachel, KeinZugriff } from "@/components/ui"; import { StatusChip, Feld, Kachel, KeinZugriff } from "@/components/ui";
import { datum, datumZeit, eur, zahl } from "@/lib/format"; import { datum, datumZeit, eur, zahl } from "@/lib/format";
import { FINDING_LABEL } from "@/lib/rechnungspruefung"; 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 }> }) { export default async function RechnungDetail({ params }: { params: Promise<{ id: string; rid: string }> }) {
const { id, rid } = await params; const { id, rid } = await params;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id); const { user, rechte, nurEigeneFirma } = await projektKontext(id);
if (!canInProject(roles, "rechnung.lesen", optionen)) if (!rechte.darf("rechnung.lesen"))
return <KeinZugriff bereich="Die Rechnungsprüfung" />; return <KeinZugriff bereich="Die Rechnungsprüfung" />;
const r = await prisma.invoice.findUnique({ const r = await prisma.invoice.findUnique({
@@ -122,10 +121,10 @@ export default async function RechnungDetail({ params }: { params: Promise<{ id:
vorschlagKuerzung={Number(r.kuerzungNetto)} vorschlagKuerzung={Number(r.kuerzungNetto)}
netto={Number(r.nettoBetrag)} netto={Number(r.nettoBetrag)}
// Prüfen und Bescheinigen hängen am Rollenrecht, nicht am Projektschalter // Prüfen und Bescheinigen hängen am Rollenrecht, nicht am Projektschalter
darfPruefen={can(roles, "rechnung.lesen")} darfPruefen={rechte.darf("rechnung.lesen")}
darfSachlich={can(roles, "rechnung.sachlich")} darfSachlich={rechte.darf("rechnung.sachlich")}
darfRechnerisch={can(roles, "rechnung.rechnerisch") && r.sachlichById !== user.id} darfRechnerisch={rechte.darf("rechnung.rechnerisch") && r.sachlichById !== user.id}
darfFreigeben={can(roles, "rechnung.freigeben")} darfFreigeben={rechte.darf("rechnung.freigeben")}
historie={{ historie={{
sachlich: r.sachlichBy ? `${r.sachlichBy.name}, ${datumZeit(r.sachlichAm)}` : null, sachlich: r.sachlichBy ? `${r.sachlichBy.name}, ${datumZeit(r.sachlichAm)}` : null,
rechnerisch: r.rechnerischBy ? `${r.rechnerischBy.name}, ${datumZeit(r.rechnerischAm)}` : 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)} {eur(b.differenz)}
</div> </div>
)} )}
{!b.erledigt && can(roles, "rechnung.sachlich") && <BefundErledigen findingId={b.id} />} {!b.erledigt && rechte.darf("rechnung.sachlich") && <BefundErledigen findingId={b.id} />}
</div> </div>
</div> </div>
</li> </li>
@@ -2,16 +2,15 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { pruefeRechnung } from "@/lib/rechnungspruefung"; import { pruefeRechnung } from "@/lib/rechnungspruefung";
import { benachrichtige, protokolliere } from "@/lib/notify"; import { benachrichtige, protokolliere } from "@/lib/notify";
export async function pruefungStarten(invoiceId: string) { export async function pruefungStarten(invoiceId: string) {
const user = await requireUser(); const user = await requireUser();
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } }); const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId); const rechte = await rechteImProjekt(user.id, rechnung.projectId);
assertCan(roles, "rechnung.lesen"); rechte.pruefe("rechnung.lesen");
const bericht = await pruefeRechnung(invoiceId); const bericht = await pruefeRechnung(invoiceId);
await protokolliere({ await protokolliere({
@@ -30,8 +29,8 @@ export async function sachlichRichtig(formData: FormData) {
where: { id: invoiceId }, where: { id: invoiceId },
include: { befunde: { where: { erledigt: false, schwere: "KRITISCH" } } }, include: { befunde: { where: { erledigt: false, schwere: "KRITISCH" } } },
}); });
const roles = await rolesInProject(user.id, rechnung.projectId); const rechte = await rechteImProjekt(user.id, rechnung.projectId);
assertCan(roles, "rechnung.sachlich"); rechte.pruefe("rechnung.sachlich");
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: rechnung.projectId } }); const projekt = await prisma.project.findUniqueOrThrow({ where: { id: rechnung.projectId } });
const ignorieren = formData.get("trotzBefunden") === "on"; const ignorieren = formData.get("trotzBefunden") === "on";
@@ -95,8 +94,8 @@ export async function rechnerischRichtig(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const invoiceId = String(formData.get("invoiceId")); const invoiceId = String(formData.get("invoiceId"));
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } }); const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId); const rechte = await rechteImProjekt(user.id, rechnung.projectId);
assertCan(roles, "rechnung.rechnerisch"); rechte.pruefe("rechnung.rechnerisch");
if (rechnung.status !== "SACHLICH_RICHTIG") if (rechnung.status !== "SACHLICH_RICHTIG")
throw new Error("Die sachliche Richtigkeit muss zuerst durch die Bauüberwachung bescheinigt werden."); 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 user = await requireUser();
const invoiceId = String(formData.get("invoiceId")); const invoiceId = String(formData.get("invoiceId"));
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } }); const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId); const rechte = await rechteImProjekt(user.id, rechnung.projectId);
assertCan(roles, "rechnung.freigeben"); rechte.pruefe("rechnung.freigeben");
if (rechnung.status !== "RECHNERISCH_RICHTIG") if (rechnung.status !== "RECHNERISCH_RICHTIG")
throw new Error("Vor der Zahlungsfreigabe müssen sachliche und rechnerische Prüfung abgeschlossen sein."); 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 user = await requireUser();
const invoiceId = String(formData.get("invoiceId")); const invoiceId = String(formData.get("invoiceId"));
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } }); const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
const roles = await rolesInProject(user.id, rechnung.projectId); const rechte = await rechteImProjekt(user.id, rechnung.projectId);
assertCan(roles, "rechnung.sachlich"); rechte.pruefe("rechnung.sachlich");
await prisma.invoice.update({ await prisma.invoice.update({
where: { id: invoiceId }, where: { id: invoiceId },
@@ -184,8 +183,8 @@ export async function befundErledigen(formData: FormData) {
where: { id: findingId }, where: { id: findingId },
include: { invoice: { select: { projectId: true, id: true } } }, include: { invoice: { select: { projectId: true, id: true } } },
}); });
const roles = await rolesInProject(user.id, befund.invoice.projectId); const rechte = await rechteImProjekt(user.id, befund.invoice.projectId);
assertCan(roles, "rechnung.sachlich"); rechte.pruefe("rechnung.sachlich");
await prisma.invoiceFinding.update({ await prisma.invoiceFinding.update({
where: { id: findingId }, where: { id: findingId },
@@ -2,7 +2,6 @@ import Link from "next/link";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canInProject } from "@/lib/rbac";
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui"; import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
import { datum, eur } from "@/lib/format"; 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 }> }) { export default async function Rechnungen({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id); const { user, rechte, nurEigeneFirma } = await projektKontext(id);
if (!canInProject(roles, "rechnung.lesen", optionen)) if (!rechte.darf("rechnung.lesen"))
return <KeinZugriff bereich="Die Rechnungsprüfung" />; return <KeinZugriff bereich="Die Rechnungsprüfung" />;
const rechnungen = await prisma.invoice.findMany({ const rechnungen = await prisma.invoice.findMany({
@@ -2,8 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser, rolesInProject } from "@/lib/auth"; import { requireUser, rechteImProjekt } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { benachrichtige, protokolliere } from "@/lib/notify"; import { benachrichtige, protokolliere } from "@/lib/notify";
/** /**
@@ -19,8 +18,8 @@ export async function zettelPruefen(formData: FormData) {
where: { id: timeSheetId }, where: { id: timeSheetId },
include: { zeilen: true }, include: { zeilen: true },
}); });
const roles = await rolesInProject(user.id, zettel.projectId); const rechte = await rechteImProjekt(user.id, zettel.projectId);
assertCan(roles, "stunden.pruefen"); rechte.pruefe("stunden.pruefen");
let abweichung = false; let abweichung = false;
for (const z of zettel.zeilen) { for (const z of zettel.zeilen) {
@@ -71,8 +70,8 @@ export async function aufmassPruefen(formData: FormData) {
where: { id: measurementId }, where: { id: measurementId },
include: { zeilen: true }, include: { zeilen: true },
}); });
const roles = await rolesInProject(user.id, aufmass.projectId); const rechte = await rechteImProjekt(user.id, aufmass.projectId);
assertCan(roles, "aufmass.pruefen"); rechte.pruefe("aufmass.pruefen");
let abweichung = false; let abweichung = false;
for (const z of aufmass.zeilen) { for (const z of aufmass.zeilen) {
@@ -104,8 +103,8 @@ export async function aufmassPruefen(formData: FormData) {
export async function zettelEinreichen(formData: FormData) { export async function zettelEinreichen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
const projectId = String(formData.get("projectId")); const projectId = String(formData.get("projectId"));
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
assertCan(roles, "stunden.einreichen"); rechte.pruefe("stunden.einreichen");
const contractId = String(formData.get("contractId")); const contractId = String(formData.get("contractId"));
const anzahl = await prisma.timeSheet.count({ where: { contractId } }); const anzahl = await prisma.timeSheet.count({ where: { contractId } });
@@ -1,6 +1,5 @@
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { can } from "@/lib/rbac";
import { StatusChip, Kachel, Leer } from "@/components/ui"; import { StatusChip, Kachel, Leer } from "@/components/ui";
import { datum, eur, zahl } from "@/lib/format"; import { datum, eur, zahl } from "@/lib/format";
import { ZettelPruefung } from "./ZettelPruefung"; import { ZettelPruefung } from "./ZettelPruefung";
@@ -22,9 +21,9 @@ const ART_LABEL: Record<string, string> = {
export default async function Stunden({ params }: { params: Promise<{ id: string }> }) { export default async function Stunden({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { user, roles, nurEigeneFirma } = await projektKontext(id); const { user, rechte, nurEigeneFirma } = await projektKontext(id);
const darfPruefen = can(roles, "stunden.pruefen"); const darfPruefen = rechte.darf("stunden.pruefen");
const darfEinreichen = can(roles, "stunden.einreichen"); const darfEinreichen = rechte.darf("stunden.einreichen");
// Datenauswahl hängt ausschließlich an der Rolle ein Projektschalter // Datenauswahl hängt ausschließlich an der Rolle ein Projektschalter
// darf niemals fremde Firmendaten sichtbar machen. // darf niemals fremde Firmendaten sichtbar machen.
@@ -254,7 +253,7 @@ export default async function Stunden({ params }: { params: Promise<{ id: string
</header> </header>
<div className="p-4"> <div className="p-4">
{a.bemerkung && <p className="mb-2 text-xs text-db-muted">{a.bemerkung}</p>} {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 <AufmassPruefung
measurementId={a.id} measurementId={a.id}
zeilen={a.zeilen.map((z) => ({ zeilen={a.zeilen.map((z) => ({
@@ -1,6 +1,5 @@
import { projektKontext } from "@/lib/auth"; import { projektKontext } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canInProject } from "@/lib/rbac";
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui"; import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
import { datum, eur, zahl } from "@/lib/format"; 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 }> }) { export default async function Vertraege({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id); const { user, rechte, nurEigeneFirma } = await projektKontext(id);
if (!canInProject(roles, "vertrag.lesen", optionen)) if (!rechte.darf("vertrag.lesen"))
return <KeinZugriff bereich="Der Bereich Verträge & Nachträge" />; return <KeinZugriff bereich="Der Bereich Verträge & Nachträge" />;
const vertraege = await prisma.contract.findMany({ const vertraege = await prisma.contract.findMany({
@@ -19,7 +19,7 @@ export function ProjektFormular({
eigeneId, eigeneId,
}: { }: {
personen: Person[]; personen: Person[];
rollen: { wert: string; label: string }[]; rollen: { wert: string; label: string; hinweis: string; eigen: boolean; seite: string }[];
fachbereicheNetz: string[]; fachbereicheNetz: string[];
fachbereicheBahnhof: string[]; fachbereicheBahnhof: string[];
masken: { netz: number[]; bahnhof: number[] }; masken: { netz: number[]; bahnhof: number[] };
@@ -290,6 +290,7 @@ export function ProjektFormular({
{rollen.map((r) => ( {rollen.map((r) => (
<option key={r.wert} value={r.wert}> <option key={r.wert} value={r.wert}>
{r.label} {r.label}
{r.eigen ? " (eigene Rolle)" : ""}
</option> </option>
))} ))}
</select> </select>
+2 -10
View File
@@ -3,8 +3,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth"; import { requireUser, darfProjekteAnlegen } from "@/lib/auth";
import { can } from "@/lib/rbac";
import { slotsErzeugen, meilensteineAusTerminen } from "@/lib/projekte"; import { slotsErzeugen, meilensteineAusTerminen } from "@/lib/projekte";
import { protokolliere, benachrichtige } from "@/lib/notify"; import { protokolliere, benachrichtige } from "@/lib/notify";
import type { Bauherr, ProjectStatus, Role } from "@prisma/client"; 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) { export async function projektAnlegen(formData: FormData) {
const user = await requireUser(); const user = await requireUser();
// Das Recht ist global wer irgendwo Projektleitung ist, darf anlegen. if (!(await darfProjekteAnlegen(user.id, user.isAdmin))) {
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")) {
throw new Error("Projekte dürfen nur Projektleitung und Administration anlegen."); 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 { ArrowLeft } from "lucide-react";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db"; 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 { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
import { fachbereichsMasken } from "@/lib/projekte"; import { fachbereichsMasken } from "@/lib/projekte";
import { rollenAuswahl } from "@/lib/rollenauswahl";
import { darfProjekteAnlegen } from "@/lib/auth";
import { ProjektFormular } from "./ProjektFormular"; import { ProjektFormular } from "./ProjektFormular";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function NeuesProjekt() { export default async function NeuesProjekt() {
const user = await requireUser(); const user = await requireUser();
const eigene = await prisma.projectMember.findMany({ if (!(await darfProjekteAnlegen(user.id, user.isAdmin))) redirect("/projekte?fehler=kein-zugriff");
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");
const [personen, masken] = await Promise.all([ const [personen, masken, rollen] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where: { active: true }, where: { active: true },
select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true, type: true } } }, select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true, type: true } } },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}), }),
fachbereichsMasken([...FACHBEREICHE_NETZ, ...FACHBEREICHE_BAHNHOF]), fachbereichsMasken([...FACHBEREICHE_NETZ, ...FACHBEREICHE_BAHNHOF]),
rollenAuswahl(),
]); ]);
const standardOptionen = Object.fromEntries( const standardOptionen = Object.fromEntries(
@@ -58,7 +54,7 @@ export default async function NeuesProjekt() {
firma: p.company?.shortName ?? p.company?.name ?? null, firma: p.company?.shortName ?? p.company?.name ?? null,
extern: p.company?.type === "AUFTRAGNEHMER" || p.company?.type === "SICHERUNGSUNTERNEHMEN", 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} fachbereicheNetz={FACHBEREICHE_NETZ}
fachbereicheBahnhof={FACHBEREICHE_BAHNHOF} fachbereicheBahnhof={FACHBEREICHE_BAHNHOF}
masken={masken} masken={masken}
+7 -10
View File
@@ -4,8 +4,8 @@ import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { StatusChip, Fortschritt, Leer } from "@/components/ui"; import { StatusChip, Fortschritt, Leer } from "@/components/ui";
import { datum, eur } from "@/lib/format"; import { datum, eur } from "@/lib/format";
import { ROLE_LABEL, can } from "@/lib/rbac"; import { rollenBezeichnung } from "@/lib/rbac";
import type { Role } from "@prisma/client"; import { darfProjekteAnlegen } from "@/lib/auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -23,7 +23,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
const projekte = await prisma.project.findMany({ const projekte = await prisma.project.findMany({
where: user.isAdmin ? {} : { members: { some: { userId: user.id } } }, where: user.isAdmin ? {} : { members: { some: { userId: user.id } } },
include: { include: {
members: { where: { userId: user.id }, select: { role: true } }, members: { where: { userId: user.id }, select: { role: true, customRole: { select: { name: true } } } },
abschnitte: { take: 1 }, abschnitte: { take: 1 },
_count: { select: { documents: true, issues: true, contracts: true } }, _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) } }, where: { projectId: { in: projekte.map((p) => p.id) } },
_count: true, _count: true,
}); });
// Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist, darf anlegen. // Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist
const eigeneRollen: Role[] = [ // über eine Standardrolle oder eine eigene Rolle darf anlegen.
...new Set(projekte.flatMap((p) => p.members.map((m) => m.role))), const darfAnlegen = await darfProjekteAnlegen(user.id, user.isAdmin);
...(user.isAdmin ? (["ADMIN"] as Role[]) : []),
];
const darfAnlegen = can(eigeneRollen, "projekt.anlegen");
const grad = new Map<string, { gesamt: number; fertig: number; offen: number }>(); const grad = new Map<string, { gesamt: number; fertig: number; offen: number }>();
for (const s of slotStat) { for (const s of slotStat) {
@@ -120,7 +117,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
</dl> </dl>
<div className="mt-3 flex items-center justify-between text-[11px] text-db-muted"> <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> <span>IBN {datum(p.inbetriebnahme)}</span>
</div> </div>
</Link> </Link>
+15 -12
View File
@@ -4,8 +4,7 @@ import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Kachel, StatusChip, Leer, Fortschritt } from "@/components/ui"; import { Kachel, StatusChip, Leer, Fortschritt } from "@/components/ui";
import { datum, eur, tageBis } from "@/lib/format"; import { datum, eur, tageBis } from "@/lib/format";
import { ROLE_LABEL, isExternalOnly } from "@/lib/rbac"; import { rollenBezeichnung, ROLE_SEITE } from "@/lib/rbac";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,7 +13,10 @@ export default async function Uebersicht() {
const mitgliedschaften = await prisma.projectMember.findMany({ const mitgliedschaften = await prisma.projectMember.findMany({
where: { userId: user.id }, 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))]; 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." />; 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, // Projekte trennen: in welchen steht der Nutzer auf der Bauherrenseite,
// in welchen Auftragnehmer? Für Letztere gilt überall die Firmenschranke. // in welchen auf der Auftragnehmerseite? Für Letztere gilt die Firmenschranke.
const rollenJeProjekt = new Map<string, Role[]>(); // 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) { 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( const intern = projektIds.filter((pid) => user.isAdmin || aufAGSeite.get(pid) === true);
(pid) => user.isAdmin || !isExternalOnly(rollenJeProjekt.get(pid) ?? []),
);
const extern = projektIds.filter((pid) => !intern.includes(pid)); const extern = projektIds.filter((pid) => !intern.includes(pid));
const eigeneFirma = user.companyId ?? "__keine__"; const eigeneFirma = user.companyId ?? "__keine__";
@@ -124,14 +127,14 @@ export default async function Uebersicht() {
proProjekt.set(s.projectId, e); proProjekt.set(s.projectId, e);
} }
const rollen = [...new Set(mitgliedschaften.map((m) => m.role))]; const rollen = [...new Set(mitgliedschaften.map((m) => rollenBezeichnung(m)))];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-xl font-semibold text-white">Guten Tag, {user.name.split(" ")[0]}.</h1> <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"> <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"} {projektIds.length === 1 ? "Projekt" : "Projekte"}
</p> </p>
</div> </div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { ROLE_LABEL } from "@/lib/rbac"; import { rollenBezeichnung } from "@/lib/rbac";
import { Benutzerliste } from "./Benutzerliste"; import { Benutzerliste } from "./Benutzerliste";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,7 +12,7 @@ export default async function BenutzerVerwaltung() {
prisma.user.findMany({ prisma.user.findMany({
include: { include: {
company: { select: { id: true, name: true, shortName: true } }, 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 } }, _count: { select: { sessions: true } },
}, },
orderBy: [{ active: "desc" }, { name: "asc" }], orderBy: [{ active: "desc" }, { name: "asc" }],
@@ -40,7 +40,7 @@ export default async function BenutzerVerwaltung() {
firma: b.company?.shortName ?? b.company?.name ?? null, firma: b.company?.shortName ?? b.company?.name ?? null,
lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null, lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null,
sitzungen: b._count.sessions, 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, projekte: b.memberships.length,
}))} }))}
firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))} 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 { prisma } from "@/lib/db";
import { import {
PERMISSIONS, PERMISSION_INFO, PERMISSION_GRUPPEN, ROLE_LABEL, ROLE_HINT, 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"; } from "@/lib/rbac";
import { Rolleneditor } from "./Rolleneditor";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,7 +24,13 @@ export default async function Rollenlegende() {
const rollen = Object.keys(ROLE_LABEL) as Role[]; const rollen = Object.keys(ROLE_LABEL) as Role[];
const rechte = Object.keys(PERMISSIONS) as Permission[]; 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])); const anzahl = new Map(belegung.map((b) => [b.role, b._count]));
return ( return (
@@ -43,7 +51,37 @@ export default async function Rollenlegende() {
</p> </p>
</section> </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) => { {(["AG", "AN", "NEUTRAL"] as const).map((seite) => {
const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite); const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite);
if (gruppe.length === 0) return null; if (gruppe.length === 0) return null;
@@ -51,6 +89,9 @@ export default async function Rollenlegende() {
<section key={seite} className="card overflow-hidden"> <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"> <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 ${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"> <span className="text-xs text-db-muted">
{seite === "AG" && "vergibt, überwacht, prüft, gibt frei sieht das gesamte Projekt"} {seite === "AG" && "vergibt, überwacht, prüft, gibt frei sieht das gesamte Projekt"}
{seite === "AN" && "liefert, meldet, rechnet ab sieht ausschließlich die eigene Firma"} {seite === "AN" && "liefert, meldet, rechnet ab sieht ausschließlich die eigene Firma"}
@@ -100,6 +141,11 @@ export default async function Rollenlegende() {
{ROLE_KURZ[r]} {ROLE_KURZ[r]}
</th> </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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -109,7 +155,7 @@ export default async function Rollenlegende() {
return ( return (
<Fragmentartig key={gruppe}> <Fragmentartig key={gruppe}>
<tr className="border-t border-db-line/70 bg-db-dark/40"> <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} {gruppe}
</td> </td>
</tr> </tr>
@@ -140,6 +186,21 @@ export default async function Rollenlegende() {
</td> </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> </tr>
); );
})} })}
@@ -206,6 +267,8 @@ export default async function Rollenlegende() {
<li>· Auftragnehmer sehen ausschließlich Daten der eigenen Firma.</li> <li>· Auftragnehmer sehen ausschließlich Daten der eigenen Firma.</li>
<li>· Wer nicht im Projekt eingetragen ist, sieht das Projekt nicht auch nicht in der Liste.</li> <li>· 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>· 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> </ul>
</section> </section>
</div> </div>
+4 -5
View File
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getSessionUser, rolesInProject } from "@/lib/auth"; import { getSessionUser, rechteImProjekt } from "@/lib/auth";
import { isExternalOnly } from "@/lib/rbac";
import { leseDatei } from "@/lib/storage"; import { leseDatei } from "@/lib/storage";
import { protokolliere } from "@/lib/notify"; 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 }); if (!version) return new NextResponse("Nicht gefunden.", { status: 404 });
const roles = await rolesInProject(user.id, version.document.projectId); const rechte = await rechteImProjekt(user.id, version.document.projectId);
if (roles.length === 0) return new NextResponse("Kein Zugriff.", { status: 403 }); if (rechte.leer) return new NextResponse("Kein Zugriff.", { status: 403 });
if (version.document.vertraulich !== "OFFEN" && isExternalOnly(roles)) if (version.document.vertraulich !== "OFFEN" && rechte.nurEigeneFirma)
return new NextResponse("Kein Zugriff auf diese Unterlage.", { status: 403 }); return new NextResponse("Kein Zugriff auf diese Unterlage.", { status: 403 });
let daten: Buffer; let daten: Buffer;
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getSessionUser, rolesInProject } from "@/lib/auth"; import { getSessionUser, rechteImProjekt } from "@/lib/auth";
import { UEBERGABE_LABEL } from "@/lib/fristen"; import { UEBERGABE_LABEL } from "@/lib/fristen";
/** Export des Soll-Ist-Standes bewusst im Format der Ursprungs-Excel. */ /** 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 { id } = await params;
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) return new NextResponse("Nicht angemeldet.", { status: 401 }); 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 }); return new NextResponse("Kein Zugriff.", { status: 403 });
const akte = new URL(req.url).searchParams.get("akte"); 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 { randomBytes } from "crypto";
import { prisma } from "./db"; import { prisma } from "./db";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
import { rechtelage, rechteDerRolle, OPTIONEN_OFFEN, type Rechtelage, type EigeneRolle } from "./rbac";
const COOKIE = "baudoc_session"; const COOKIE = "baudoc_session";
const SESSION_DAYS = 12; const SESSION_DAYS = 12;
@@ -109,16 +110,47 @@ export async function rolesInProject(userId: string, projectId: string): Promise
where: { userId, projectId }, where: { userId, projectId },
select: { role: true }, 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"); if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
return roles; 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) { export async function requireProjectAccess(projectId: string) {
const user = await requireUser(); const user = await requireUser();
const roles = await rolesInProject(user.id, projectId); const rechte = await rechteImProjekt(user.id, projectId);
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff"); if (rechte.leer) redirect("/projekte?fehler=kein-zugriff");
return { user, roles }; return { user, rechte };
} }
/** /**
@@ -128,21 +160,38 @@ export async function requireProjectAccess(projectId: string) {
*/ */
export async function projektKontext(projectId: string) { export async function projektKontext(projectId: string) {
const user = await requireUser(); 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 } }); const projekt = await prisma.project.findUnique({ where: { id: projectId } });
if (!projekt) redirect("/projekte?fehler=nicht-gefunden"); 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 { optionenAus } = await import("./projekte");
const { isExternalOnly } = await import("./rbac");
return { return {
user, user,
roles, rechte,
projekt, projekt,
optionen: optionenAus(projekt), optionen: optionenAus(projekt),
/** true = darf ausschließlich Daten der eigenen Firma sehen */ /** 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")),
);
}
+111 -37
View File
@@ -142,6 +142,15 @@ export const PERMISSION_GRUPPEN = [
"Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration", "Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration",
] as const; ] 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> = { export const ROLE_KURZ: Record<Role, string> = {
ADMIN: "ADM", ADMIN: "ADM",
PROJEKTLEITER: "PL", PROJEKTLEITER: "PL",
@@ -199,22 +208,44 @@ export const ROLE_HINT: Record<Role, string> = {
LESER: "Nur lesen.", LESER: "Nur lesen.",
}; };
/** // ═══════════════════════════════════════════════════════════════════
* Auftragnehmer und andere Externe sehen ausschließlich Daten der eigenen Firma. // Rechte einer Rolle
* // ═══════════════════════════════════════════════════════════════════
* Wichtig: Diese Funktion steuert die DATENAUSWAHL und darf niemals durch eine
* Projekteinstellung aufgeweicht werden. Die Schalter unter `ProjektOptionen` /** Alle Rechte, die eine Standardrolle mitbringt. */
* entscheiden nur, ob ein Bereich überhaupt sichtbar ist nicht, wessen Daten export function rechteDerRolle(role: Role): Permission[] {
* darin erscheinen. return (Object.keys(PERMISSIONS) as Permission[]).filter((p) =>
*/ (PERMISSIONS[p] as readonly Role[]).includes(role),
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, 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 // Projektbezogene Einstellungen
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -288,34 +319,77 @@ const ENTZIEHBAR_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>
"dokument.hochladen": "anDarfDokumente", "dokument.hochladen": "anDarfDokumente",
}; };
// ═══════════════════════════════════════════════════════════════════
// Rechtelage einer Person in einem Projekt
// ═══════════════════════════════════════════════════════════════════
/** /**
* Berechtigung unter Berücksichtigung der Projekteinstellungen. * Bündelt, was jemand in einem Projekt darf aus Standardrollen und eigenen
* Für Rollen der Bauherrenseite identisch mit `can()`. * Rollen zusammengeführt. Einzige Stelle, an der Rechte ausgewertet werden.
*/ */
export function canInProject( export type Rechtelage = {
roles: Role[], /** zugewiesene Standardrollen */
permission: Permission, 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, optionen: ProjektOptionen,
): boolean { ): Rechtelage {
const extern = isExternalOnly(roles); const leer = rollen.length === 0 && eigene.length === 0;
if (extern) { // Bauherrenseite: mindestens eine Rolle steht auf der Auftraggeberseite.
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission]; const aufAGSeite =
if (entzug && !optionen[entzug]) return false; 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]; const grundmenge = new Set<string>();
if (gewaehrung && optionen[gewaehrung]) return true; 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( /** Voreinstellung, wenn keine Projektschalter im Spiel sind (z. B. Verwaltung). */
roles: Role[], export const OPTIONEN_OFFEN: ProjektOptionen = {
permission: Permission, anSichtVertrag: true,
optionen: ProjektOptionen, anSichtRechnungen: true,
) { anSichtBautagebuch: true,
if (!canInProject(roles, permission, optionen)) { anSichtDokumentation: true,
throw new Error(`Keine Berechtigung für "${permission}".`); 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 };
}