Merge lane/einsatz in feature/craftvia-mvp

Konflikte gelöst: processors/index.ts (report-pdf + image-derivatives),
(app)/layout.tsx (Logo, Glocke, AccountInactiveNotice). L5-Mobilseiten
report/sign nach src/app/(field)/m/(core)/orders/[id]/ verschoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:32:35 +02:00
co-authored by Claude Opus 5
76 changed files with 5608 additions and 66 deletions
+9 -55
View File
@@ -1,10 +1,8 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { auth, signOut } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { isTokenStillValid } from "@/server/sessions";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requireAppAccess } from "@/server/app-access";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button";
@@ -14,60 +12,16 @@ import { TenantSwitcher } from "@/components/tenant-switcher";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { NotificationBell } from "@/components/notifications/bell";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
export default async function AppLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
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 (
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
</form>
</div>
</main>
);
}
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 dieses Layouts).
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");
}
}
// Session-, Konto-, Passwort- und MFA-Prüfungen: gemeinsam mit der Mobile-Shell
// (src/server/app-access.ts).
const access = await requireAppAccess();
if (access.kind === "inactive") return <AccountInactiveNotice />;
const { session, identity } = access;
const t = await getTranslations("nav");
const tc = await getTranslations("common");