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:
co-authored by
Claude Opus 5
parent
8af6b384ea
commit
84eb93c59f
@@ -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])
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
);
|
||||
}
|
||||
|
||||
+111
-37
@@ -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",
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Rechtelage einer Person in einem Projekt
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Berechtigung unter Berücksichtigung der Projekteinstellungen.
|
||||
* Für Rollen der Bauherrenseite identisch mit `can()`.
|
||||
* Bündelt, was jemand in einem Projekt darf – aus Standardrollen und eigenen
|
||||
* Rollen zusammengeführt. Einzige Stelle, an der Rechte ausgewertet werden.
|
||||
*/
|
||||
export function canInProject(
|
||||
roles: Role[],
|
||||
permission: Permission,
|
||||
export type Rechtelage = {
|
||||
/** zugewiesene Standardrollen */
|
||||
rollen: Role[];
|
||||
/** zugewiesene selbst angelegte Rollen */
|
||||
eigene: EigeneRolle[];
|
||||
/** keinerlei Zuordnung – kein Zugriff auf das Projekt */
|
||||
leer: boolean;
|
||||
/** true = sieht ausschließlich Daten der eigenen Firma */
|
||||
nurEigeneFirma: boolean;
|
||||
/** Klartext aller Rollen, für Anzeige */
|
||||
bezeichnungen: string[];
|
||||
/** Rechteprüfung inklusive der Projektschalter */
|
||||
darf: (permission: Permission) => boolean;
|
||||
/** wie darf(), wirft aber bei fehlender Berechtigung */
|
||||
pruefe: (permission: Permission) => void;
|
||||
};
|
||||
|
||||
export function rechtelage(
|
||||
rollen: Role[],
|
||||
eigene: EigeneRolle[],
|
||||
optionen: ProjektOptionen,
|
||||
): boolean {
|
||||
const extern = isExternalOnly(roles);
|
||||
): Rechtelage {
|
||||
const leer = rollen.length === 0 && eigene.length === 0;
|
||||
|
||||
if (extern) {
|
||||
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
|
||||
if (entzug && !optionen[entzug]) return false;
|
||||
// Bauherrenseite: mindestens eine Rolle steht auf der Auftraggeberseite.
|
||||
const aufAGSeite =
|
||||
rollen.some((r) => ROLE_SEITE[r] === "AG" || r === "ADMIN") ||
|
||||
eigene.some((e) => e.seite === "AG");
|
||||
const nurEigeneFirma = !leer && !aufAGSeite;
|
||||
|
||||
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
|
||||
if (gewaehrung && optionen[gewaehrung]) return true;
|
||||
}
|
||||
const grundmenge = new Set<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);
|
||||
|
||||
return can(roles, permission);
|
||||
const darf = (permission: Permission): boolean => {
|
||||
if (nurEigeneFirma) {
|
||||
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
|
||||
if (entzug && !optionen[entzug]) return false;
|
||||
|
||||
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
|
||||
if (gewaehrung && optionen[gewaehrung] && grundmenge.size > 0) return true;
|
||||
}
|
||||
return grundmenge.has(permission);
|
||||
};
|
||||
|
||||
return {
|
||||
rollen,
|
||||
eigene,
|
||||
leer,
|
||||
nurEigeneFirma,
|
||||
bezeichnungen: [...rollen.map((r) => ROLE_LABEL[r]), ...eigene.map((e) => e.name)],
|
||||
darf,
|
||||
pruefe: (permission) => {
|
||||
if (!darf(permission)) throw new Error(`Keine Berechtigung für „${PERMISSION_INFO[permission].label}“.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function assertCanInProject(
|
||||
roles: Role[],
|
||||
permission: Permission,
|
||||
optionen: ProjektOptionen,
|
||||
) {
|
||||
if (!canInProject(roles, permission, optionen)) {
|
||||
throw new Error(`Keine Berechtigung für "${permission}".`);
|
||||
}
|
||||
}
|
||||
/** Voreinstellung, wenn keine Projektschalter im Spiel sind (z. B. Verwaltung). */
|
||||
export const OPTIONEN_OFFEN: ProjektOptionen = {
|
||||
anSichtVertrag: true,
|
||||
anSichtRechnungen: true,
|
||||
anSichtBautagebuch: true,
|
||||
anSichtDokumentation: true,
|
||||
anDarfDokumente: true,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user