import { redirect } from "next/navigation"; import type { Session } from "next-auth"; import { auth, signOut } from "@/server/auth"; import { dbForTenant, prisma } from "@/server/db"; import { isTokenStillValid } from "@/server/sessions"; import { resolveMfaRequired } from "@/lib/mfa-policy"; /** * Gemeinsame Zugriffsprüfung der angemeldeten Mandanten-Shells (Backoffice `(app)/layout.tsx` * und Mobile `(field)/m/layout.tsx`). Aus dem Backoffice-Layout extrahiert (ARCHITEKTUR §5), * damit beide Shells identisch prüfen: * Session → aktiver Mandant → Kill-Switch → Konto-/Identity-Status → Passwortzwang → MFA-Gate. * Redirects werden hier ausgelöst; ein deaktiviertes Konto wird als `inactive` gemeldet, * die aufrufende Shell rendert dafür `AccountInactiveNotice`. */ export type AppAccess = | { kind: "inactive" } | { kind: "ok"; session: Session; identity: { uiLocale: string } }; export async function requireAppAccess(): Promise { const session = await auth(); if (!session?.user) redirect("/login"); // Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) → // Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId). if (!session.user.tenantId) redirect("/select-tenant"); // Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden // abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom // User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity. const account = await dbForTenant(session.user.tenantId).user.findUnique({ where: { id: session.user.id }, select: { status: true }, }); const identity = session.user.identityId ? await prisma.identity.findUnique({ where: { id: session.user.identityId }, select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true }, }) : null; // SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden. if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) { await signOut({ redirectTo: "/login" }); } if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") { return { kind: "inactive" }; } if (identity.mustChangePassword) redirect("/change-password"); // SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP // noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb der Shells). if (!identity.mfaEnrolledAt) { const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } }); if (resolveMfaRequired(settings?.securityPolicy)) { const passkeys = session.user.identityId ? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } }) : 0; if (passkeys === 0) redirect("/enroll-mfa"); } } return { kind: "ok", session, identity: { uiLocale: identity.uiLocale } }; } /** * Startseite nach dem Login (ARCHITEKTUR §5): Feldrollen (Einsatz ausführen, keine * Gesamtsicht auf Aufträge) landen in der Mobile-App `/m`, alle anderen im Backoffice. * Reiner UX-Komfort — die Zielseiten prüfen Rechte selbst. */ export function landingPath(session: Session | null): string { const perms = session?.user?.permissions ?? []; return perms.includes("field:execute") && !perms.includes("work_order:read_all") ? "/m" : "/dashboard"; }