Erstimport. Fachliche Grundlage ist die EIU-Ablagestruktur des Auftraggebers (2-EIU-Ablagestruktur.xlsx): 1.106 Zeilen, aufbereitet zu 159 Ordnern und 944 Soll-Unterlagen mit Fachbereichsrelevanz, Aufbewahrungsfrist, Medium und Übergabezeitpunkt. Module - Dokumentationsstand: Soll-Ist je Projekt, Fristen aus dem Übergabezeitpunkt und den Projektterminen, Ampel, CSV-Rückexport im Format der Ursprungsdatei - Dokumentenmanagement mit Versionen, Freigabelauf, SHA-256-Prüfsummen - Bautagebuch nach Ril 809.0301, nach Abschluss gesperrt - Meldungen und Mängel (Behinderung, Bedenken, Mangel, AvL, Unfall) - Verträge, Nachträge, Aufmaße, Stundenlohnzettel mit zeilenweiser Anerkennung - Rechnungsprüfung: automatischer Abgleich gegen Vertrag, anerkanntes Aufmaß, bestätigte Stundenzettel und Vorrechnungen; getrennte Bescheinigung sachlich richtig / rechnerisch richtig / Zahlungsfreigabe - Kommunikation mit Projektkanälen, Erwähnungen und E-Mail-Benachrichtigung - Revisionssicheres Protokoll aller Vorgänge Rechte Rollen nach dem Vorgabeprozess Bauüberwachung (PL, BHV, BÜB, FBÜ, kaufmännische Steuerung, Planung, Auftragnehmer, Prüfer, Betreiber), Zuordnung je Projekt. Auftragnehmer sehen ausschließlich Daten der eigenen Firma; diese Schranke hängt allein an der Rolle. Welche Bereiche ihnen überhaupt angezeigt werden, entscheidet der Bauherr je Projekt über Schalter in den Einstellungen. Betrieb Docker Compose mit Anwendung, PostgreSQL und Mailpit. Keine Named Volumes – alle persistenten Daten liegen als Bind-Mount unter data/ im Compose- Verzeichnis, damit ein Backup ein Kopiervorgang bleibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
import "server-only";
|
|
import { cookies, headers } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import bcrypt from "bcryptjs";
|
|
import { randomBytes } from "crypto";
|
|
import { prisma } from "./db";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
const COOKIE = "baudoc_session";
|
|
const SESSION_DAYS = 12;
|
|
|
|
export type SessionUser = {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
isAdmin: boolean;
|
|
companyId: string | null;
|
|
companyName: string | null;
|
|
companyType: string | null;
|
|
};
|
|
|
|
export async function hashPassword(pw: string) {
|
|
return bcrypt.hash(pw, 11);
|
|
}
|
|
|
|
export async function verifyPassword(pw: string, hash: string) {
|
|
return bcrypt.compare(pw, hash);
|
|
}
|
|
|
|
export async function login(email: string, password: string) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: email.trim().toLowerCase() },
|
|
include: { company: true },
|
|
});
|
|
if (!user || !user.active) return { ok: false as const, error: "Anmeldung fehlgeschlagen." };
|
|
if (!(await verifyPassword(password, user.passwordHash)))
|
|
return { ok: false as const, error: "Anmeldung fehlgeschlagen." };
|
|
|
|
const token = randomBytes(32).toString("hex");
|
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 864e5);
|
|
const h = await headers();
|
|
|
|
await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
token,
|
|
expiresAt,
|
|
userAgent: h.get("user-agent")?.slice(0, 250) ?? null,
|
|
ip: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
|
|
},
|
|
});
|
|
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
|
await prisma.auditLog.create({
|
|
data: { userId: user.id, aktion: "LOGIN", entitaet: "User", entitaetId: user.id },
|
|
});
|
|
|
|
const store = await cookies();
|
|
store.set(COOKIE, token, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: process.env.NODE_ENV === "production" && (process.env.APP_URL ?? "").startsWith("https"),
|
|
path: "/",
|
|
expires: expiresAt,
|
|
});
|
|
return { ok: true as const };
|
|
}
|
|
|
|
export async function logout() {
|
|
const store = await cookies();
|
|
const token = store.get(COOKIE)?.value;
|
|
if (token) await prisma.session.deleteMany({ where: { token } });
|
|
store.delete(COOKIE);
|
|
}
|
|
|
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
|
const store = await cookies();
|
|
const token = store.get(COOKIE)?.value;
|
|
if (!token) return null;
|
|
|
|
const session = await prisma.session.findUnique({
|
|
where: { token },
|
|
include: { user: { include: { company: true } } },
|
|
});
|
|
if (!session || session.expiresAt < new Date() || !session.user.active) return null;
|
|
|
|
return {
|
|
id: session.user.id,
|
|
email: session.user.email,
|
|
name: session.user.name,
|
|
isAdmin: session.user.isAdmin,
|
|
companyId: session.user.companyId,
|
|
companyName: session.user.company?.name ?? null,
|
|
companyType: session.user.company?.type ?? null,
|
|
};
|
|
}
|
|
|
|
export async function requireUser(): Promise<SessionUser> {
|
|
const user = await getSessionUser();
|
|
if (!user) redirect("/anmelden");
|
|
return user;
|
|
}
|
|
|
|
/** Rollen des Nutzers im Projekt (Admins gelten überall als ADMIN). */
|
|
export async function rolesInProject(userId: string, projectId: string): Promise<Role[]> {
|
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } });
|
|
const memberships = await prisma.projectMember.findMany({
|
|
where: { userId, projectId },
|
|
select: { role: true },
|
|
});
|
|
const roles = memberships.map((m) => m.role);
|
|
if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
|
|
return roles;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Zugriff prüfen und zugleich das Projekt samt seiner Einstellungen laden.
|
|
* Seiten sollten diese Variante verwenden, damit die projektbezogenen
|
|
* Sichtbarkeitsschalter nirgends vergessen werden.
|
|
*/
|
|
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 { optionenAus } = await import("./projekte");
|
|
const { isExternalOnly } = await import("./rbac");
|
|
|
|
return {
|
|
user,
|
|
roles,
|
|
projekt,
|
|
optionen: optionenAus(projekt),
|
|
/** true = darf ausschließlich Daten der eigenen Firma sehen */
|
|
nurEigeneFirma: isExternalOnly(roles),
|
|
};
|
|
}
|