Rolleneditor: eigene Rollen neben den Standardrollen

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

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

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

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

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